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
GitHub Actions (publish-mods)chore: Update mods/ 5a2676e · 4 days ago History
399 lines · 34.0 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`, `prompt.attachment`, `skill.prompt`, `attribution.text` | Continue past the user tier: managed CLAUDE.md, rules and policy skills, and what the engine injects for the model beside them (a settings hook's note, the instruction files, a mode's reminder), are not a person's plugin's to reword at these events. A person's plugins keep `prompt.submit` and its additive context. This does not cover the variables that turn off CLAUDE.md loading: a person's plugin or settings can set `CLAUDE_CODE_DISABLE_CLAUDE_MDS`, `CLAUDE_CODE_SIMPLE` or `CLAUDE_CODE_SAFE_MODE`, and no CLAUDE.md loads, the managed one included. |
30| `session.append` | For a row the engine injects for the model (the `attachment` and `hook-context` doors: a reminder, a settings hook's context), for a compaction's rows (the `compaction` door: its summary and boundary), for a skill's body as a call of the Skill tool hands it over (the `tool-message` door, that tool's rows alone, since the row does not say whose skill it is) and for a note an organization's plugin appends, continue past the user tier: the row is kept as made, so what passed `prompt.attachment` whole is not blanked or reworded on its way into the conversation. For a skill the hold does that much and no more: it stops rewording of the row in transit. A person's `tool.call` hook can still answer for a Skill call or swap `input.skill`, as it can for any tool; `allowManagedModsOnly` closes that. Every other row (a prompt, a response, a tool result, another tool's message, a delivery, a command's row, a notice, a note of a person's plugin, and a note the engine itself makes, a Stop hook's feedback among them) passes as the chain leaves it. What is held of a hook is its verdict and the context it adds; the words a hook's block feeds back to the model are such an engine-made note. |
31| `session.compact` | Continue past the user tier, whole: a conversation a person's plugin answers in place of a compaction is never installed, so the conversation a compaction leaves is the engine's or the organization's own making, and its summary is stored as made (the `session.append` row). A person's own compaction hook does not run where this plugin is seated; a plugin of theirs may still ask for a compaction, which the engine then makes. |
32| `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. |
33| `settings.read` | Continue past the user tier: no user hook rewrites what any caller reads as settings, this plugin's own policy reads included. |
34| `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. |
35| `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). With no allowlist, one is still refused whose tool, called `mcp__<plugin>__<tool>`, would read as a tool of a server the organization delivers: a `managedMcpServers` entry, or a server of the enterprise `managed-mcp.json`, which the policy read names under that key. So is one whose name reads as a plugin the organization seats itself (`prependPlugins`, `appendPlugins`, or an id managed `enabledPlugins` turns on, by `true` or by a version list, as the engine reads an entry), or as an MCP server such a plugin's own manifest declares, which the engine spells under the plugin's scope (`mcp__plugin_<plugin>_<server>__`). A `user`-tier plugin keeps its own name exactly when the id the engine recorded for it (`<name>@<marketplace>`: the settings key the loader resolved, never the plugin's manifest) is one managed `enabledPlugins` turn on, compared as the engine compares ids (NFC, then lower case). That is how an organization's own plugin registers its tools where the engine leaves it in the `user` tier: only a copy read in place from a local folder marketplace that managed settings name is vouched for, so a plugin from a remote marketplace (git, GitHub, npm) can never be vouched for in this release, and neither is one a version list turns on. For an organization that means such a plugin runs among a person's plugins in the chain, not ahead of them, and keeps its tools by this exemption alone. A plugin loaded with `--plugin-dir` is always `<name>@inline` and never matches; nor does a same-named plugin from another marketplace, one whose name only folds to the organization's, or an id policy lists only in `prependPlugins` or `appendPlugins`. A person who registers a marketplace of their own under the organization's marketplace name gets an equal id, and so the exemption: it gives that plugin what every plugin had before this hold existed, and `strictKnownMarketplaces` in managed settings closes it today by allowing no other marketplace source. The mod hears a plugin's id at its admission, so after a load that seats the mod afresh while other plugins stay seated (a load that folds again, as a policy change mid-session can cause) the organization's own plugin is refused its name until the session restarts. A plugin's own MCP servers are connected by the engine from its manifest, not registered through `tool.register`, so they are unaffected. An id's name is everything before its last at-sign, as the engine parts ids, so a scoped `@scope/name@npm` counts. A `managed-mcp.json` that is present and cannot be read whole names nobody: the policy read marks it (`managedMcpServersUnreadable`) and every such registration is refused while it stands, its servers never taken for none; the only other reader of the mark is `tool.list` (the next row), so a person's plugins still load and still set what is not pinned. A server the file names and the engine dropped with a warning is still named; a warning about a server that loads is no failure. Names are compared folded, as a model reads them (spelled as the engine spells a name inside a tool's name, every character outside letters, digits, `-` and `_` a `_` and nothing normalized; then lower case, every run of `-` and `_` one `_`; a seated plugin counts under its name composed and decomposed, since the engine spells its tools from its manifest's own form): a plugin named as the server in any case or with other separators, one whose name and tool together spell it, and one whose tool's own name folds to nothing, is refused. A server whose name folds to nothing (no letter or digit in it) cannot be told from any name, so while the policy names one every `user`-tier registration is refused. The engine pins the tool's name at this event (a hook beneath that hands on another name fails, and the chain goes on without it), so the name judged is the name registered. Otherwise it passes. |
36| `tool.list` | The tools of the organization's managed MCP servers, of the plugins it seats itself and of the MCP servers those plugins' own manifests declare (told by the name whole, as the engine spells it inside a tool's name: `mcp__<server>__`, never by a fold, so another server whose name only begins like the organization's is not taken for it; a seated plugin and its own servers, under its scope `mcp__plugin_<plugin>_`, fold to fold as the registration check compares, so policy may list its id in another letter case, and another plugin whose name begins with the seated one's and a `-` or `_` reads the same) are listed as the organization's tiers listed them; every other tool as the user tier left it. Where the server file cannot be read, every `mcp__` tool counts as the organization's. With no policy to read, or a refusal from either listing, the organization's listing stands whole. |
37| `tool.check` | A deny, an ask a settings rule, a classic hook, a setting's own check or an organization's ceiling on the tool stands behind, an ask a host answers in the person's place and an ask a built-in or an organization's plugin made, hold whole over the user tier: when a person's plugin rewrote the verdict it was handed without tightening it (a more permissive decision, or the same decision under another reason or with what it names changed), the dispatch is run again past the user tier, and unless the chain's answer is the stricter, that verdict, if it is a deny, names what stands behind it or is an ask a host answers, is the answer as the engine gave it. See [Deny rules hold](#deny-rules-hold). Every other verdict passes as the chain left it. |
38| `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. |
39| `env.get` | A read by a plugin in `prepend` or `append` continues past the user tier: no user hook answers what the organization's own plugin reads. Every other read passes. |
40| `env.set` | A write by a plugin in `prepend` or `append` continues past the user tier, whatever the variable: no user hook rewrites or drops what the organization's own plugin sets. A variable managed settings set in `env` (its name in any case) is pinned, and so are `CLAUDE_CODE_PROCESS_WRAPPER` where they name a launcher with `processWrapper` and, always, the nine variables that move where policy and sign-in are read from or how policy's tiers compose (`CLAUDE_CODE_MANAGED_SETTINGS_PATH`, `CLAUDE_CODE_REMOTE_SETTINGS_PATH`, `CLAUDE_CODE_MOCK_REMOTE_SETTINGS`, `CLAUDE_CODE_DISABLE_ADMIN_ENV_UNION`, `CLAUDE_CONFIG_DIR`, `CLAUDE_SECURESTORAGE_CONFIG_DIR`, `USE_LOCAL_OAUTH`, `USE_STAGING_OAUTH` and `CLAUDE_LOCAL_OAUTH_API_BASE`, which choose the server sign-in goes to; all nine on every build), and, on every build too, ten variables that can load code into a child the engine starts (`LD_PRELOAD`, `LD_LIBRARY_PATH`, `LD_AUDIT`, `DYLD_INSERT_LIBRARIES`, `DYLD_LIBRARY_PATH`, `DYLD_FRAMEWORK_PATH`, `BASH_ENV`, `ENV`, `ZDOTDIR`, `NODE_OPTIONS`): setting or unsetting it, a `user`-tier caller is refused by name and a built-in's write continues past the user tier. Any other variable is a person's plugin's to set. With no policy to read, every variable counts as pinned. `allowModsToOverrideDenyRules` does not unpin. See [Pinned variables](#pinned-variables). |
41| `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. |
42| everything else | Passes: `prompt.submit`, `turn.*`, `tool.call`, `command.run`, `command.register`, `command.list`, `agent.register`, `config.*`, `prompt.fill`, `prompt.suggest`, `prompt.edit`, `prompt.read`, `session.*` but `session.append` and `session.compact`, `ui.*` but `ui.log`, `fs.*`, `http.fetch`, `process.run`, `store.*`, `clock.*`, `model.*`, `mcp.call`, `audio.*`, `agent.list`, `engine.create`. |
43
44## Options an administrator sets
45
46Two, in managed settings, under this plugin's own `pluginConfigs` entry,
47keyed by the id the CLI builds the plugin in under (only this spelling of the
48id is read):
49
50```json
51{
52 "pluginConfigs": {
53 "cc-plugin-sec-default@builtin": {
54 "options": { "allowManagedModsOnly": true }
55 }
56 }
57}
58```
59
60`allowManagedModsOnly`: only the mods the organization deploys through
61managed settings, and the ones built into Claude Code, load. A hooks module
62a person installed, named with `--plugin-dir` or keeps in their mods folder
63is refused whenever it loads or reloads (one already running when the option
64is set keeps running until then), with one line that names it: `mods are
65limited to your organization's by policy (allowManagedModsOnly); <plugin>
66was not loaded`
67(in the debug log, and on screen where the session hot-reloads the mod's
68folder). A plain `-p` run has it in the debug log alone; the mod is still
69not loaded. Settings hooks, status lines and `/goal` are not touched by it.
70
71- The decision reads the tier the CLI pins on the module and nothing the
72 module says of itself: a person's copy carrying an organization mod's name
73 is still in the `user` tier and is refused.
74- Refused means nothing of the module joins: no hook, no tool, no command.
75 Its top-level code has run once by then, in the closed context every hooks
76 module is evaluated in, with no call on `$` served.
77- Only managed settings are read (`$.settings.read({ source: "policy" })`):
78 the same entry in a person's, a project's or a `--settings` file neither
79 turns it on nor off. On unless absent or `false`, so a mistyped `"true"` or
80 `1` still locks. A value the settings schema rejects (`null`, an object),
81 here or in any other `pluginConfigs` entry, makes the CLI ignore the whole
82 `pluginConfigs` key with a settings warning, and the option reads as unset.
83- It fails closed: when the read of managed settings is refused (a hook beneath
84 denies it) or this plugin's hook fails, its `.catch` refuses the module,
85 with the same line, and names the failure in the debug log; so a policy
86 that cannot be read keeps every person's mod out at load. Where this plugin
87 is not seated there is no such rule, and mods load as they do without it.
88- Where managed settings define `prependPlugins`, that list must name this
89 plugin (next section) for the option to apply.
90- `claude plugin test` is not covered: it runs a mod's tests in an engine of
91 their own and loads nothing into a session.
92
93`allowModsToOverrideDenyRules`: the plugins a person installs may answer
94over a settings deny rule on `tool.check`, as they could before this plugin
95held deny rules, and over every other deny, an ask rule, a classic hook's
96ask, a setting's own ask or an ask a host answers or a plugin made with it: the one option
97covers all that holds there. Off unless it is the
98literal `true`; an option that reads as unset leaves them holding. See
99[Deny rules hold](#deny-rules-hold).
100
101## What it hooks
102
103`classic.*`, `prompt.section`, `prompt.context`, `prompt.attachment`,
104`prompt.compose`, `skill.prompt`,
105`attribution.text`, `session.append`, `session.compact`, `settings.read`, `tool.describe`, `command.describe`,
106`agent.offer`, `agent.spawn`, `tool.register`, `tool.list`, `tool.check`,
107`ui.log`, `env.get`, `env.set`, `plugin.register`.
108
109Hooking `tool.check` has a cost: the engine raises that event only when some
110loaded plugin hooks it, so where this plugin is seated every tool call now
111runs the `tool.check` chain, where before only a session with such a plugin
112did. So has hooking `ui.log`, `env.get` and `env.set`: each such call of any
113plugin's now runs a chain, and an `env.set` from outside the organization's tiers waits on
114the policy read.
115
116## What it calls on `$`
117
118`settings.read`, and `ui.log`: to the debug log, and for the one line a
119person reads when something held over a plugin of theirs. It continues to
120the `append` tier with `next.to`, which only a plugin in a managed tier may
121do.
122
123## Deny rules hold
124
125On `tool.check` any hook may answer any verdict, so a plugin a person
126installs to stop the permission prompts (`() => ({ decision: "allow" })`)
127would also lift a deny rule, an ask rule or a `PreToolUse` hook's ask, a
128managed one included, the read block's ask on a shell command, and the ask a
129host was to answer. Where this plugin is seated it does not:
130
131- The hook first runs the chain as it is. If no link that may hold a
132 person's plugin rewrote the verdict handed up to it (a more permissive
133 decision, or the same decision under another reason, rule or hook; a
134 tightening is no rewrite), the answer passes: nothing of the person's
135 changed anything, and a plugin that only listens, or only tightens, adds no
136 run of its own.
137 This is read off `next.trace`, whose tiers the engine pins: a link that never
138 called `next` is measured against a deny, and since the engine lists
139 neighbouring plugins that share a worker as one batch under its first
140 member's tier, a `prepend` entry beneath this plugin counts as well as a
141 `user` one. Whether a person's plugin did the rewriting is never settled
142 here, only by the next step.
143- Otherwise it runs the dispatch once more with the user tier left out
144 (`next.to(e, "append")`). That verdict never passed through a person's
145 plugin, so neither the decision nor the rule it names can have been
146 rewritten or erased, and a plugin that answered without calling `next`
147 changes nothing: the rules are evaluated in this run. The two runs differ
148 by the user tier alone, so where they differ and the chain's answer is not
149 the stricter, the user tier rewrote it. A deny is returned in place of the
150 chain's answer whatever decided it; an ask, when it names what decided it,
151 a settings rule (`rule`), a classic hook (`hook`), a setting whose own
152 check asked (`setting`) or an organization's ask cap on the tool
153 (`ceiling`) or a built-in's or an organization's plugin that made it
154 (`plugin`), and an ask a host answers (`e.approver`). It is returned
155 whole, so the reason this event answers, which a person reads in the
156 prompt and the engine writes into a refusal, is the engine's, and what it
157 names is still named. (The refusal then enters the conversation as a tool
158 result, a row a person's plugin may still reword at `session.append`.) A
159 stricter answer of theirs (a deny over an ask) stands.
160- Any rule, any classic hook, any such setting and any such cap counts,
161 whoever configured it: a verdict carries the rule as written, the hook's
162 event, the setting's key and the cap, never where any was read from. A
163 deny holds unnamed because
164 it can stand in front of such an ask: lifted, the call would run with nobody
165 asked. An ask that names none of them and that the person answers at a
166 dialog (the mode's own, a check of the engine's own that no setting turns
167 on) is not held.
168- A plugin: an ask a hook makes names the plugin (`plugin`, the engine's
169 word, never the hook's), and no classifier's allow settles it. The run past
170 the user tier holds no person's plugin, so a plugin named there is a
171 built-in or an organization's: that ask holds, and a person's plugin neither
172 allows it nor rewords it. A person's own plugin may ask too, over an allow
173 or the mode's own ask: there its ask stands, because it is stricter. Its
174 rewording of an ask that holds is held, with the notice. As for any hook's
175 ask, a rule for the whole tool is not kept; a narrower rule the person saves
176 is theirs and settles nothing while the hook asks.
177- The setting the engine names today is the read block's
178 (`permissions.blockReadsOutsideWorkingDirectories`), for the two asks the
179 block keeps for the person: about a shell command that reads outside the
180 working directories, and, while the block is on, about a command the model
181 wants run outside the sandbox, which is what holds its reads in. The file
182 tools refuse such a read outright, and a deny holds as it is.
183- A host: where the permission callback of the program that runs the session
184 answers its asks (an SDK host's `can_use_tool`, a permission prompt tool;
185 a cloud session's host is one), the event says so (`e.approver` is
186 `host`), in every loop of the process, a subagent's included, and only
187 where the host would be asked. Such an ask holds, the mode's own
188 too: it goes on to whatever the mode puts between an ask and the host
189 (auto mode's classifier) and then to the callback, as it would with no
190 plugin loaded. An evaluation the engine has not classified is held.
191- Where no host can be asked on an evaluation, it names none, and a
192 person's plugin's allow answers as it does with no host. That is a closed
193 list, pinned by a test: an in-process teammate's own function (under a
194 headless leader its ask is cancelled or never answered); the comment
195 auto-edit's own checks (one that is not an allow ends in a notice); the
196 observer's gate (an ask there is a deny); a fork, subtask, workflow or
197 forked command that has no session function of its own; and a skill's
198 inline shell command where no hand-off exists, since an ask then fails
199 the render. With a hand-off, in auto mode, that command's ask is left to
200 the model's own call, which the host is asked about: it holds.
201- An ask that is certain to be refused with nobody asked names no host, so
202 a person's plugin's allow answers it as it does in a session no host
203 runs: under `dontAsk`, in a loop no prompt can show in (a background
204 agent's; in auto mode there, with no chain, the plugin's allow comes
205 before the classifier, as it does on main), once a permission prompt
206 tool's server has left the session. An ask only a dialog on this computer
207 can show holds under a prompt tool and on a served call: the engine denies
208 it there, and a plugin's allow does not turn that deny into an allow.
209 What is not certain holds: every other mode, a
210 Chrome tool's ask under `dontAsk` (a site grant sends it on), and, where
211 the host is set to be asked again after every allow auto mode's
212 classifier gives, a loop no prompt can show in.
213- A call this computer runs for a cloud session names that session as its
214 host: its ask goes there and holds. Serving marks nothing for this
215 computer's own session: its calls name a host only if one answers them.
216- The two hosts the engine knows to show the question to the person on a
217 card (the IDE pane, the desktop app's Code tab) are that person's dialog
218 and are not named. The session's start says which it is, once, for the
219 hold and for the card alike; nothing written later changes it. Only the
220 host's own request is such a card: a permission prompt tool's question
221 holds in a pane too. The
222 entrypoint is asserted by the start environment, not proven: whoever sets
223 the environment a session is spawned with can claim a pane.
224- An organization's plugin (prepend or append) or a built-in that allows
225 over a deny rule takes part in both runs, so its answer stands (a prepended
226 one that loosens is what brings the second run about, so its hooks run
227 twice on such a call). The mode's own ask
228 that a person's plugin turns into an allow, with nothing named behind it
229 and the person the one who would have been asked, stands: that is what such
230 a plugin is for.
231- `tool.check` pins the question (`tool`, `input`, `tool_use_id`), so no hook
232 can have the rules evaluated on one command and another run; a rewrite
233 belongs to `tool.call`, which runs before any of this.
234- The person is told once for each name and kind in a session, in the
235 transcript and the debug log: `<plugin> tried to lift a deny rule in your
236 settings from a <tool> call (<rule>); the deny rule holds over the plugins
237 you install (allowModsToOverrideDenyRules)`, or `tried to reword` where
238 the decision was kept and its words or its rule were not. The kinds are `a deny rule`,
239 `an ask rule`, `a <event> hook's ask`, `a <key> setting's ask`, `a
240 <plugin>'s ask`, `an ask cap`, `a host's ask` and `a refusal` (a deny that names no rule); only the two rule kinds
241 carry `in your settings` and a rule. Plugins the
242 engine ran as one
243 batch are named together, as it names them (`audit+easy`). A plain `-p`
244 run has it in the debug log alone; the call is still denied, or asked
245 about, with its own message. The line goes past the user tier (the `ui.log`
246 row): a hook of theirs that heard it would run inside this plugin's own
247 `tool.check` hook, and the engine leaves a hook out of whatever is raised
248 from inside it.
249- What this does not reach: a held ask is still an ask, so whatever answers
250 asks for the person answers it, a classic `PermissionRequest` hook of
251 theirs included (`allowManagedHooksOnly` is the control for those). Nor an
252 ask of the engine's own check that no setting turns on (a dangerous
253 removal's): at a dialog the verdict does not tell it from the mode's own,
254 so a person's plugin may still answer it there. Nor a session nobody
255 answers asks for (a plain `-p` run, where an ask is refused): the mode's
256 own ask is a person's plugin's to allow there. Nor a hosted session where
257 this plugin is not seated: a person's plugin answers the mode's ask there
258 as it does at a dialog. Nor an ask an organization's own plugin answers
259 without passing on the engine's verdict: it names nothing unless that
260 plugin names it.
261- It does not reach a person's classic `PreToolUse` hook that allows
262 either: the engine then raises no ask at all, `tool.check` never runs, and
263 the host is not asked. That is the same with no plugin loaded.
264- If the hook itself fails, its `.catch` answers from the one run it can
265 read: a verdict no plugin of the person's rewrote stands, one they
266 tightened included; one they rewrote, or a run that rejected, is refused
267 in this plugin's own words, since the rules and hooks were never consulted.
268
269An organization that wants the plugins its people install to override deny
270rules, and all else that holds with them, says so in managed settings, under
271this plugin's own options:
272
273```json
274{
275 "pluginConfigs": {
276 "cc-plugin-sec-default@builtin": {
277 "options": { "allowModsToOverrideDenyRules": true }
278 }
279 }
280}
281```
282
283Only the managed source is read (`$.settings.read({ source: "policy" })`),
284so the same key in a person's, a project's or a local settings file, or in
285`--settings`, is never consulted; only the literal `true` counts, and a
286policy that cannot be read leaves them all holding. So does a policy no
287administrator composed: where the only policy is a document the person can
288write themselves (the Windows `HKCU` hive), or a remote policy the server
289has not yet confirmed this session, the option is not taken from it.
290That policy still holds what it holds; it grants nothing.
291
292## Pinned variables
293
294`$.env.set` writes the session's own environment. A variable managed
295settings set in `env` is the organization's to decide, so this plugin
296refuses a person's plugin that sets or unsets one (the `env.set` row). It
297refuses the same of the launcher's variable where policy names a launcher,
298and, on every build and whatever managed settings hold, of the nine
299variables that move where policy and sign-in are read from: a write of one
300would point a later load or launch at another policy. An
301organization's own plugin's reads, writes and log lines continue past the
302user tier's hooks (the `env.get`, `env.set` and `ui.log` rows).
303
304It refuses the same of ten variables that can load code into a child the
305engine starts, a managed hook's command among them: the loader's
306(`LD_PRELOAD`, `LD_LIBRARY_PATH`, `LD_AUDIT`, `DYLD_INSERT_LIBRARIES`,
307`DYLD_LIBRARY_PATH`, `DYLD_FRAMEWORK_PATH`), a shell's start file
308(`BASH_ENV`, `ENV`, `ZDOTDIR`) and the runtime's options (`NODE_OPTIONS`).
309Each reaches some children, not all: `BASH_ENV` a bash that is not
310interactive, `ENV` an interactive shell in sh mode, `NODE_OPTIONS` a Node
311child, and macOS drops the `DYLD_` names for protected binaries.
312That pin is a list, not a boundary. A person's plugin can start a process
313as the person by other roads, and other variables steer a child too
314(`PATH`, the home variables, a credential file's path). The pin keeps the
315organization's hook commands and tools from being re-pointed by accident
316or by a careless plugin, not by a hostile one.
317
318- At a load, from the first admission until the set serves, an op a plugin
319 makes (one it left running between the load's steps too) is raised through
320 the plugins admitted so far, this one first: a pinned variable is
321 refused to a person's `plugin.register` hook then as at any later time.
322- What this does not reach: a variable that managed `env` does not set and
323 that is not among those names is a person's plugin's to write, and the
324 children the engine starts afterwards (Bash, an MCP server, `$.process.run`,
325 a person's own hook) start from the environment as written.
326- A hook the organization defines is the exception: one in managed settings,
327 one of a plugin managed settings install, and the managed status line and
328 file suggestion commands. A plugin counts as one managed settings install
329 only when it is read in place from a folder those settings name; a plugin
330 they merely enable by id, loaded from a copy on the person's machine (a
331 marketplace download, or the person's own folder under that id), is not
332 treated as the organization's, for its hooks or for its writes. The
333 organization's hook's command starts from the environment, and is
334 wrapped in the `CLAUDE_CODE_SHELL_PREFIX`, as they stood before any write a
335 person's plugin made with `$.env.set` in this process: a variable such a
336 plugin set or deleted reads as it did before, absent if it was absent. A
337 managed HTTP hook's header variables are read the same way, and so is the
338 time allowed to the hooks that run as a session ends. What an
339 organization's plugin, or one built into the CLI, writes still reaches the
340 hook, also where it writes a variable a person's plugin wrote first; a
341 value a later settings reload puts over the person's plugin's value stands
342 too. A write counts as the organization's only when no person's plugin
343 stands behind it. A write made while serving a person's plugin, or in a
344 call a person's plugin raised, is a person's; so is an organization's
345 write whose value a hook of a person's plugin changed before it was made.
346- A person still decides the environment Claude Code starts in (`PATH`,
347 `LD_PRELOAD`, `CLAUDE_CODE_SHELL_PREFIX` are theirs to set before launch,
348 and in their own settings files), and a managed hook starts from that
349 environment like every other child. The same holds for the proxy and
350 certificate, credential, provider and home variables (`HTTPS_PROXY`,
351 `NODE_EXTRA_CA_CERTS`, `ANTHROPIC_API_KEY`, `HOME` and their like): they are
352 not pinned, because a person sets the same ones in their own settings and
353 shell, so a pin on `env.set` would not close that road. Where that road is
354 closed, the engine closes it for a plugin too, with or without this
355 plugin seated: in a session that asks its organization for its settings (a
356 Cowork cloud session), a plugin that is not built in sets or unsets only a
357 variable the filters on every settings `env` would let a person's own
358 settings file set there, and any other write (the proxy and certificate
359 variables among them) is held and answered `{ deny }`. A plugin the
360 container's managed settings enable is held alike: the session may be able
361 to write that file. So is a write, or an unset, of a name any settings tier
362 above the person's sets there (the project's, the local, a `--settings`
363 file's or the organization's own settings `env`), and a built-in's own
364 write in three corners: the built-in serves a plugin that is not built in,
365 it writes from a dispatch such a plugin raised, or it writes beneath a call
366 some hook rewrote, since who rewrote it cannot be told. An organization
367 pins the variables its hooks rely on by setting them in managed `env`. An
368 organization's plugin's `$.env.get` is answered past the user tier's hooks,
369 with the environment as written.
370
371## Where it is seated
372
373The CLI seats it first in the prepend tier wherever hooks modules load on a
374machine with managed settings or a managed-mcp.json, or for a Team or
375Enterprise organization,
376unless managed settings define `prependPlugins`: then that list is the
377whole prepend tier, and the organization names `sec-default@builtin` in it
378at the position it wants, e.g. `"prependPlugins": ["acme-guard@acme-tools",
379"sec-default@builtin"]`, or leaves it out. Once seated by the machine's
380managed settings or by the plan, it stays seated until the session ends: a
381plan that reads differently at a later reload (a token variable a plugin
382set, an `apiKeyHelper` that appeared in a person's settings, another
383sign-in) does not unseat it. How a person signs in before a launch is
384theirs, so a seat that rests on the plan alone follows the sign-in at the
385next launch; managed settings on the machine are what hold it there. The
386organization's own `prependPlugins` list still decides at every reload,
387with one exception: a Cowork cloud session that asks its organization for
388its managed settings seats it by default, and there a `prependPlugins` list
389that leaves it out does not unseat it. The only such list a session there
390can hold is a file's in its container, which the session can write as well
391as its environment can, so it may add a hold and never lift one. That
392default ends once the organization's 200 has set nothing, the server
393has named a Pro or Max plan for the launch's login and the host's plan
394variable agrees: it is then known that nothing seats it, as in the
395terminal, and a policy served later brings it back. It
396is a plugin folder like any
397other, but its one move that matters, `next.to`, is refused outside a
398managed tier, so loading it with `--plugin-dir` seats a plugin that can
399only pass.