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

chore: Update mods/Browse files
GitHub Actions (publish-mods) authored 10 days ago parent b3caf75 · commit bf2323b3021c
22 changed files+601 -39compared with b3caf75
Mmods/sec-default/README.md
Amods/sec-default/hooks/held-verdict/ask-kind.ts
Mmods/sec-default/hooks/held-verdict/held-kind.ts
Mmods/sec-default/hooks/held-verdict/index.ts
Mmods/sec-default/hooks/held-verdict/verdicts/holds-over.ts
Mmods/sec-default/hooks/held-verdict/verdicts/is-same-verdict.ts
Mmods/sec-default/hooks/hooks.json
Amods/sec-default/hooks/policy/code-loading-variables.ts
Mmods/sec-default/hooks/policy/index.ts
Mmods/sec-default/hooks/policy/pinned-names.ts
Mmods/sec-default/tests/fixtures/index.ts
Amods/sec-default/tests/fixtures/setting-bash-env.ts
Amods/sec-default/tests/fixtures/setting-dyld-insert.ts
Amods/sec-default/tests/fixtures/setting-ld-preload.ts
Amods/sec-default/tests/fixtures/setting-node-options.ts
Amods/sec-default/tests/fixtures/tool-check/host-ask.ts
Mmods/sec-default/tests/fixtures/tool-check/index.ts
Amods/sec-default/tests/fixtures/tool-check/setting-ask.ts
Amods/sec-default/tests/fixtures/unsetting-node-options.ts
Mmods/sec-default/tests/held-verdict.test.ts
Mmods/sec-default/tests/register.test.ts
Mmods/types/claude-code.d.ts
@@ -34,10 +34,10 @@
3434 | `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. |
3535 | `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. |
3636 | `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, and an ask a settings rule, a classic hook or an organization's ceiling on the tool stands behind, 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, rule or hook), 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 or names the rule, the hook or the ceiling behind it, is the answer as the engine gave it. See [Deny rules hold](#deny-rules-hold). Every other verdict passes as the chain left it. |
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, and an ask a host answers in the person's place, 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 or names what stands behind it or the host it is put to, is the answer as the engine gave it. See [Deny rules hold](#deny-rules-hold). Every other verdict passes as the chain left it. |
3838 | `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. |
3939 | `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): 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). |
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). |
4141 | `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. |
4242 | 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`. |
4343
@@ -92,8 +92,9 @@
9292
9393 `allowModsToOverrideDenyRules`: the plugins a person installs may answer
9494 over a settings deny rule on `tool.check`, as they could before this plugin
95-held deny rules, and over every other deny, an ask rule or a classic hook's
96-ask with it: the one option covers all that holds there. Off unless it is the
95+held deny rules, and over every other deny, an ask rule, a classic hook's
96+ask, a setting's own ask or an ask a host answers with it: the one option
97+covers all that holds there. Off unless it is the
9798 literal `true`; an option that reads as unset leaves them holding. See
9899 [Deny rules hold](#deny-rules-hold).
99100
@@ -124,7 +125,8 @@
124125 On `tool.check` any hook may answer any verdict, so a plugin a person
125126 installs to stop the permission prompts (`() => ({ decision: "allow" })`)
126127 would also lift a deny rule, an ask rule or a `PreToolUse` hook's ask, a
127-managed one included. Where this plugin is seated it does not:
128+managed one included, the read block's ask on a shell command, and the ask a
129+host was to answer. Where this plugin is seated it does not:
128130
129131 - The hook first runs the chain as it is. If no link that may hold a
130132 person's plugin rewrote the verdict handed up to it (a more permissive
@@ -145,25 +147,77 @@
145147 changes nothing: the rules are evaluated in this run. The two runs differ
146148 by the user tier alone, so where they differ and the chain's answer is not
147149 the stricter, the user tier rewrote it. A deny is returned in place of the
148- chain's answer whatever decided it; an ask, when it names what decided it:
149- a settings rule (`rule`), a classic hook (`hook`) or an organization's ask
150- cap on the tool (`ceiling`). It is returned whole,
151- so the reason this event answers, which a person reads in the prompt and the
152- engine writes into a refusal, is the engine's, and the rule or hook is still
153- named. (The refusal then enters the conversation as a tool result, a row a
154- person's plugin may still reword at `session.append`.) A stricter answer of
155- theirs (a deny over an ask) stands.
156-- Any rule and any classic hook counts, whoever configured it: a verdict
157- carries the rule as written and the hook's event, never where either was
158- read from. A deny holds unnamed because it can stand in front of a rule's or
159- a hook's ask: lifted, the call would run with nobody asked. An ask that names
160- neither (the mode's own, a check of the engine's own) is not held.
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 names a host that answers it (`decider`). It is returned
154+ whole, so the reason this event answers, which a person reads in the
155+ prompt and the engine writes into a refusal, is the engine's, and what it
156+ names is still named. (The refusal then enters the conversation as a tool
157+ result, a row a person's plugin may still reword at `session.append`.) A
158+ stricter answer of theirs (a deny over an ask) stands.
159+- Any rule, any classic hook, any such setting and any such cap counts,
160+ whoever configured it: a verdict carries the rule as written, the hook's
161+ event, the setting's key and the cap, never where any was read from. A
162+ deny holds unnamed because
163+ it can stand in front of such an ask: lifted, the call would run with nobody
164+ asked. An ask that names none of them and that the person answers at a
165+ dialog (the mode's own, a check of the engine's own that no setting turns
166+ on) is not held.
167+- The setting the engine names today is the read block's
168+ (`permissions.blockReadsOutsideWorkingDirectories`), for the two asks the
169+ block keeps for the person: about a shell command that reads outside the
170+ working directories, and, while the block is on, about a command the model
171+ wants run outside the sandbox, which is what holds its reads in. The file
172+ tools refuse such a read outright, and a deny holds as it is.
173+- A host: where the permission callback of the program that runs the session
174+ answers its asks (an SDK host's `can_use_tool`, a permission prompt tool;
175+ a cloud session's host is one), the engine's own ask says so
176+ (`decider: "host"`), in every loop of the process, a subagent's included,
177+ and only where the host would be asked. Such an ask holds, the mode's own
178+ too: it goes on to whatever the mode puts between an ask and the host
179+ (auto mode's classifier) and then to the callback, as it would with no
180+ plugin loaded. An evaluation the engine has not classified is held.
181+- Where no host can be asked on an evaluation, it names none, and a
182+ person's plugin's allow answers as it does with no host. That is a closed
183+ list, pinned by a test: an in-process teammate's own function (under a
184+ headless leader its ask is cancelled or never answered); the comment
185+ auto-edit's own checks (one that is not an allow ends in a notice); the
186+ observer's gate (an ask there is a deny); a fork, subtask, workflow or
187+ forked command that has no session function of its own; and a skill's
188+ inline shell command where no hand-off exists, since an ask then fails
189+ the render. With a hand-off, in auto mode, that command's ask is left to
190+ the model's own call, which the host is asked about: it holds.
191+- An ask that is certain to be refused with nobody asked names no host, so
192+ a person's plugin's allow answers it as it does in a session no host
193+ runs: under `dontAsk`, in a loop no prompt can show in (a background
194+ agent's; in auto mode there, with no chain, the plugin's allow comes
195+ before the classifier, as it does on main), once a permission prompt
196+ tool's server has left the session. An ask only a dialog on this computer
197+ can show holds under a prompt tool and on a served call: the engine denies
198+ it there, and a plugin's allow does not turn that deny into an allow.
199+ What is not certain holds: every other mode, a
200+ Chrome tool's ask under `dontAsk` (a site grant sends it on), and, where
201+ the host is set to be asked again after every allow auto mode's
202+ classifier gives, a loop no prompt can show in.
203+- A call this computer runs for a cloud session names that session as its
204+ host: its ask goes there and holds. Serving marks nothing for this
205+ computer's own session: its calls name a host only if one answers them.
206+- The two hosts the engine knows to show the question to the person on a
207+ card (the IDE pane, the desktop app's Code tab) are that person's dialog
208+ and are not named. The session's start says which it is, once, for the
209+ hold and for the card alike; nothing written later changes it. Only the
210+ host's own request is such a card: a permission prompt tool's question
211+ holds in a pane too. The
212+ entrypoint is asserted by the start environment, not proven: whoever sets
213+ the environment a session is spawned with can claim a pane.
161214 - An organization's plugin (prepend or append) or a built-in that allows
162215 over a deny rule takes part in both runs, so its answer stands (a prepended
163216 one that loosens is what brings the second run about, so its hooks run
164217 twice on such a call). The mode's own ask
165- that a person's plugin turns into an allow, with no rule or hook behind it,
166- stands: that is what such a plugin is for.
218+ that a person's plugin turns into an allow, with nothing named behind it
219+ and the person the one who would have been asked, stands: that is what such
220+ a plugin is for.
167221 - `tool.check` pins the question (`tool`, `input`, `tool_use_id`), so no hook
168222 can have the rules evaluated on one command and another run; a rewrite
169223 belongs to `tool.call`, which runs before any of this.
@@ -172,8 +226,9 @@
172226 settings from a <tool> call (<rule>); the deny rule holds over the plugins
173227 you install (allowModsToOverrideDenyRules)`, or `tried to reword` where
174228 the decision was kept and its words or its rule were not. The kinds are `a deny rule`,
175- `an ask rule`, `a <event> hook's ask` and `a refusal` (a deny that names no
176- rule); the last two carry neither `in your settings` nor a rule. Plugins the
229+ `an ask rule`, `a <event> hook's ask`, `a <key> setting's ask`, `a host's
230+ ask` and `a refusal` (a deny that names no rule); only the two rule kinds
231+ carry `in your settings` and a rule. Plugins the
177232 engine ran as one
178233 batch are named together, as it names them (`audit+easy`). A plain `-p`
179234 run has it in the debug log alone; the call is still denied, or asked
@@ -184,10 +239,18 @@
184239 - What this does not reach: a held ask is still an ask, so whatever answers
185240 asks for the person answers it, a classic `PermissionRequest` hook of
186241 theirs included (`allowManagedHooksOnly` is the control for those). Nor an
187- ask that names neither a rule nor a hook though the engine's own check
188- raised it (the read block's ask on a shell command, a dangerous removal's):
189- the verdict does not tell it from the mode's own, so a person's plugin may
190- still answer it.
242+ ask of the engine's own check that no setting turns on (a dangerous
243+ removal's): at a dialog the verdict does not tell it from the mode's own,
244+ so a person's plugin may still answer it there. Nor a session nobody
245+ answers asks for (a plain `-p` run, where an ask is refused): the mode's
246+ own ask is a person's plugin's to allow there. Nor a hosted session where
247+ this plugin is not seated: a person's plugin answers the mode's ask there
248+ as it does at a dialog. Nor an ask an organization's own plugin answers
249+ without passing on the engine's verdict: it names nothing unless that
250+ plugin names it.
251+- It does not reach a person's classic `PreToolUse` hook that allows
252+ either: the engine then raises no ask at all, `tool.check` never runs, and
253+ the host is not asked. That is the same with no plugin loaded.
191254 - If the hook itself fails, its `.catch` answers from the one run it can
192255 read: a verdict no plugin of the person's rewrote stands, one they
193256 tightened included; one they rewrote, or a run that rejected, is refused
@@ -228,6 +291,20 @@
228291 organization's own plugin's reads, writes and log lines continue past the
229292 user tier's hooks (the `env.get`, `env.set` and `ui.log` rows).
230293
294+It refuses the same of ten variables that can load code into a child the
295+engine starts, a managed hook's command among them: the loader's
296+(`LD_PRELOAD`, `LD_LIBRARY_PATH`, `LD_AUDIT`, `DYLD_INSERT_LIBRARIES`,
297+`DYLD_LIBRARY_PATH`, `DYLD_FRAMEWORK_PATH`), a shell's start file
298+(`BASH_ENV`, `ENV`, `ZDOTDIR`) and the runtime's options (`NODE_OPTIONS`).
299+Each reaches some children, not all: `BASH_ENV` a bash that is not
300+interactive, `ENV` an interactive shell in sh mode, `NODE_OPTIONS` a Node
301+child, and macOS drops the `DYLD_` names for protected binaries.
302+That pin is a list, not a boundary. A person's plugin can start a process
303+as the person by other roads, and other variables steer a child too
304+(`PATH`, the home variables, a credential file's path). The pin keeps the
305+organization's hook commands and tools from being re-pointed by accident
306+or by a careless plugin, not by a hostile one.
307+
231308 - At a load, from the first admission until the set serves, an op a plugin
232309 makes (one it left running between the load's steps too) is raised through
233310 the plugins admitted so far, this one first: a pinned variable is
@@ -1,0 +1,26 @@
1+import type { EventResult } from 'claude-code'
2+
3+/**
4+ * Names who asked, where no rule decided a verdict that holds: the classic
5+ * hook first, then the setting whose check asked, then the host it goes to.
6+ *
7+ * An ask that names none of the three holds by the ceiling an organization
8+ * set on the tool.
9+ *
10+ * @param held the ask in question
11+ * @returns `<event> hook's ask`, `<key> setting's ask`, `host's ask`, or
12+ * `ask cap`
13+ */
14+export function askKind(held: EventResult<'tool.check'>) {
15+ const isHooked = held.hook !== undefined
16+ const isSet = held.setting !== undefined
17+ const isHosted = held.decider !== undefined
18+
19+ return isHooked
20+ ? `${held.hook} hook's ask`
21+ : isSet
22+ ? `${held.setting} setting's ask`
23+ : isHosted
24+ ? `${held.decider}'s ask`
25+ : 'ask cap'
26+}
@@ -1,11 +1,13 @@
11 import type { EventResult } from 'claude-code'
22
3+import { askKind } from './ask-kind.js'
4+
35 /**
46 * What kind of thing held over a person's plugin, as the notice calls it:
5- * `deny rule`, `ask rule`, `<event> hook's ask`, `ask cap`, or `refusal`.
7+ * `deny rule`, `ask rule`, `refusal`, or whose ask it was, else `ask cap`.
68 *
79 * A deny that names no rule is a refusal even when it names a hook: the hook
8- * may only have asked, and something else refused. An ask that names neither
10+ * may only have asked, and something else refused. An ask that names nobody
911 * holds by the ceiling an organization set on the tool.
1012 *
1113 * @param held the verdict that holds
@@ -14,13 +16,10 @@
1416 export function heldKind(held: EventResult<'tool.check'>): string {
1517 const isRuled = held.rule !== undefined
1618 const isRefused = held.decision === 'deny'
17- const isHooked = held.hook !== undefined
1819
1920 return isRuled
2021 ? `${held.decision} rule`
2122 : isRefused
2223 ? 'refusal'
23- : isHooked
24- ? `${held.hook} hook's ask`
25- : 'ask cap'
24+ : askKind(held)
2625 }
@@ -1,3 +1,4 @@
1+export * from './ask-kind.js'
12 export * from './caught-answer.js'
23 export * from './held-kind.js'
34 export * from './held-notice.js'
@@ -5,12 +5,11 @@
55
66 /**
77 * Whether the verdict reached past the user tier holds whole over the
8- * chain's answer: a deny, or an ask a rule, a hook or a ceiling stands behind.
8+ * chain's answer: a deny, or an ask that names what stands behind it.
99 *
1010 * It holds over a more permissive answer and over the same decision reworded
11- * or stripped of its rule or hook; a stricter answer stands. Any rule or
12- * classic hook counts, whoever configured it, and so does the ceiling an
13- * organization set on the tool. The mode's own ask, with none, never holds.
11+ * or stripped of what it names; a stricter answer stands. A rule, a hook, a
12+ * setting, a host or a ceiling counts; the mode's own ask at a dialog never.
1413 *
1514 * @param held what the run past the user tier settled on
1615 * @param answer what the whole chain settled on
@@ -25,4 +24,6 @@
2524 (held.decision === 'deny' ||
2625 held.rule !== undefined ||
2726 held.hook !== undefined ||
27+ held.setting !== undefined ||
28+ held.decider !== undefined ||
2829 held.ceiling !== undefined)
@@ -2,7 +2,7 @@
22
33 /**
44 * Whether two `tool.check` verdicts say the same in every field: decision,
5- * reason, and the rule, hook or ceiling either names.
5+ * reason, and whatever either names (a rule, a hook, a setting, a host).
66 *
77 * @param one a verdict
88 * @param other another, or undefined when nothing settled
@@ -1,4 +1,4 @@
11 {
2- "description": "Security default: from the outermost seat, continues past the user tier on the organization's classic hooks, prompt content, settings and subjects, at session.append for the rows the engine injects, a compaction's rows, a skill the Skill tool hands over and an organization plugin's notes, and at session.compact, refuses a user-tier tool.register under an MCP allowlist or under a name that reads as one of the organization's servers or plugins, restores the organization's tools in tool.list, holds a deny, and an ask a settings rule, a classic hook or an organization's ask cap decided, whole over what the user tier makes of it on tool.check, continues past the user tier's hooks for an organization plugin's log lines and its reads and writes of the environment, refuses a user-tier write of a variable managed env sets, of the launcher's variable where policy names a launcher and of the variables that move where policy and sign-in are read from, and refuses a user-tier hooks module at plugin.register while managed settings set its allowManagedModsOnly option",
2+ "description": "Security default: from the outermost seat, continues past the user tier on the organization's classic hooks, prompt content, settings and subjects, at session.append for the rows the engine injects, a compaction's rows, a skill the Skill tool hands over and an organization plugin's notes, and at session.compact, refuses a user-tier tool.register under an MCP allowlist or under a name that reads as one of the organization's servers or plugins, restores the organization's tools in tool.list, holds a deny, an ask a settings rule, a classic hook, a setting's own check or an organization's ask cap decided, and an ask a host answers, whole over what the user tier makes of it on tool.check, continues past the user tier's hooks for an organization plugin's log lines and its reads and writes of the environment, refuses a user-tier write of a variable managed env sets, of the launcher's variable where policy names a launcher and of the variables that move where policy and sign-in are read from, and refuses a user-tier hooks module at plugin.register while managed settings set its allowManagedModsOnly option",
33 "modules": ["./register.ts"]
44 }
@@ -1,0 +1,20 @@
1+/**
2+ * Variables that can load code into a child the engine starts: at the
3+ * loader, at a shell's start, or with the runtime's own options.
4+ *
5+ * A list, not a boundary. A person's plugin can start a process as the
6+ * person by other roads; this keeps the organization's hook commands and
7+ * tools from being re-pointed by accident or by a careless plugin.
8+ */
9+export const CODE_LOADING_VARIABLES = [
10+ 'LD_PRELOAD',
11+ 'LD_LIBRARY_PATH',
12+ 'LD_AUDIT',
13+ 'DYLD_INSERT_LIBRARIES',
14+ 'DYLD_LIBRARY_PATH',
15+ 'DYLD_FRAMEWORK_PATH',
16+ 'BASH_ENV',
17+ 'ENV',
18+ 'ZDOTDIR',
19+ 'NODE_OPTIONS',
20+] as const
@@ -1,3 +1,4 @@
1+export * from './code-loading-variables.js'
12 export * from './create-policy-memo'
23 export * from './decided-by-policy.js'
34 export * from './deny-rules-hold.js'
@@ -1,5 +1,6 @@
11 import type { Settings } from 'claude-code'
22
3+import { CODE_LOADING_VARIABLES } from './code-loading-variables.js'
34 import { POLICY_SOURCE_VARIABLES } from './policy-source-variables.js'
45 import { PROCESS_WRAPPER_VARIABLE } from './process-wrapper-variable.js'
56
@@ -7,7 +8,8 @@
78 * The environment variables managed policy sets: every name of its `env`,
89 * and the launcher's variable where it names one with `processWrapper`.
910 *
10- * Always among them: the variables that say where policy is read from.
11+ * Always among them: the variables that say where policy is read from,
12+ * and the ones that can load code into a child the engine starts.
1113 *
1214 * @param policy the managed settings, as `$.settings.read` answers them
1315 * @returns the names, as the policy spells them
@@ -19,5 +21,6 @@
1921 ...Object.keys(policy.env ?? {}),
2022 ...(isWrapped ? [PROCESS_WRAPPER_VARIABLE] : []),
2123 ...POLICY_SOURCE_VARIABLES,
24+ ...CODE_LOADING_VARIABLES,
2225 ]
2326 }
@@ -49,7 +49,11 @@
4949 export * from './seated-suite.js'
5050 export * from './server-policy.js'
5151 export * from './session.js'
52+export * from './setting-bash-env.js'
53+export * from './setting-dyld-insert.js'
54+export * from './setting-ld-preload.js'
5255 export * from './setting-lower-case.js'
56+export * from './setting-node-options.js'
5357 export * from './setting-other.js'
5458 export * from './setting-proxy.js'
5559 export * from './signing.js'
@@ -63,6 +67,7 @@
6367 export * from './tools-registered.js'
6468 export * from './tools.js'
6569 export * from './unfoldable-policy.js'
70+export * from './unsetting-node-options.js'
6671 export * from './unsetting-proxy.js'
6772 export * from './user-reachable-providers.js'
6873 export * from './variables-set.js'
@@ -1,0 +1,28 @@
1+import type { Plugin } from 'claude-code/testing'
2+
3+/**
4+ * A plugin that sets `BASH_ENV` (a shell's start file) to `mine` when the
5+ * session starts, in the tier given: the person's own by default.
6+ *
7+ * It logs a refusal and goes on.
8+ *
9+ * @param name the plugin's name
10+ * @param tier the tier it loads in
11+ * @returns the plugin
12+ */
13+export const settingBashEnv = (
14+ name: string,
15+ tier?: Plugin['tier'],
16+): Plugin => ({
17+ name,
18+ tier,
19+ register(on) {
20+ on('session.start', async ($, e, next) => {
21+ await $.env
22+ .set('BASH_ENV', 'mine')
23+ .catch((error: unknown) => $.ui.log(String(error)))
24+
25+ return next(e)
26+ })
27+ },
28+})
@@ -1,0 +1,28 @@
1+import type { Plugin } from 'claude-code/testing'
2+
3+/**
4+ * A plugin that sets `DYLD_INSERT_LIBRARIES` (the macOS loader's) to `mine`
5+ * when the session starts, in the tier given: the person's own by default.
6+ *
7+ * It logs a refusal and goes on.
8+ *
9+ * @param name the plugin's name
10+ * @param tier the tier it loads in
11+ * @returns the plugin
12+ */
13+export const settingDyldInsert = (
14+ name: string,
15+ tier?: Plugin['tier'],
16+): Plugin => ({
17+ name,
18+ tier,
19+ register(on) {
20+ on('session.start', async ($, e, next) => {
21+ await $.env
22+ .set('DYLD_INSERT_LIBRARIES', 'mine')
23+ .catch((error: unknown) => $.ui.log(String(error)))
24+
25+ return next(e)
26+ })
27+ },
28+})
@@ -1,0 +1,28 @@
1+import type { Plugin } from 'claude-code/testing'
2+
3+/**
4+ * A plugin that sets `LD_PRELOAD` (the Linux loader's) to `mine` when the
5+ * session starts, in the tier given: the person's own by default.
6+ *
7+ * It logs a refusal and goes on.
8+ *
9+ * @param name the plugin's name
10+ * @param tier the tier it loads in
11+ * @returns the plugin
12+ */
13+export const settingLdPreload = (
14+ name: string,
15+ tier?: Plugin['tier'],
16+): Plugin => ({
17+ name,
18+ tier,
19+ register(on) {
20+ on('session.start', async ($, e, next) => {
21+ await $.env
22+ .set('LD_PRELOAD', 'mine')
23+ .catch((error: unknown) => $.ui.log(String(error)))
24+
25+ return next(e)
26+ })
27+ },
28+})
@@ -1,0 +1,28 @@
1+import type { Plugin } from 'claude-code/testing'
2+
3+/**
4+ * A plugin that sets `NODE_OPTIONS` (the runtime's options) to `mine` when the
5+ * session starts, in the tier given: the person's own by default.
6+ *
7+ * It logs a refusal and goes on.
8+ *
9+ * @param name the plugin's name
10+ * @param tier the tier it loads in
11+ * @returns the plugin
12+ */
13+export const settingNodeOptions = (
14+ name: string,
15+ tier?: Plugin['tier'],
16+): Plugin => ({
17+ name,
18+ tier,
19+ register(on) {
20+ on('session.start', async ($, e, next) => {
21+ await $.env
22+ .set('NODE_OPTIONS', 'mine')
23+ .catch((error: unknown) => $.ui.log(String(error)))
24+
25+ return next(e)
26+ })
27+ },
28+})
@@ -1,0 +1,11 @@
1+import type { EventResult } from 'claude-code'
2+
3+/**
4+ * The engine's verdict in a hosted session when nothing allows or denies
5+ * the call: the mode's own ask, which the host answers.
6+ */
7+export const HOST_ASK: EventResult<'tool.check'> = {
8+ decision: 'ask',
9+ reason: 'Bash needs approval',
10+ decider: 'host',
11+}
@@ -14,6 +14,7 @@
1414 export * from './forging.js'
1515 export * from './hook-ask.js'
1616 export * from './hook-deny.js'
17+export * from './host-ask.js'
1718 export * from './link-of.js'
1819 export * from './listening.js'
1920 export * from './muting.js'
@@ -22,6 +23,7 @@
2223 export * from './rewriting.js'
2324 export * from './rule-ask.js'
2425 export * from './rule-deny.js'
26+export * from './setting-ask.js'
2527 export * from './sneaking.js'
2628 export * from './tightening.js'
2729
@@ -1,0 +1,11 @@
1+import type { EventResult } from 'claude-code'
2+
3+/**
4+ * The engine's verdict when the read block asked about a shell read outside
5+ * the working directories: the ask, its sentence, and the setting's key.
6+ */
7+export const SETTING_ASK: EventResult<'tool.check'> = {
8+ decision: 'ask',
9+ reason: 'cat names a path outside the working directories',
10+ setting: 'permissions.blockReadsOutsideWorkingDirectories',
11+}
@@ -1,0 +1,28 @@
1+import type { Plugin } from 'claude-code/testing'
2+
3+/**
4+ * A plugin that unsets `NODE_OPTIONS` when the session starts, in the tier
5+ * given: the person's own by default.
6+ *
7+ * It logs a refusal and goes on.
8+ *
9+ * @param name the plugin's name
10+ * @param tier the tier it loads in
11+ * @returns the plugin
12+ */
13+export const unsettingNodeOptions = (
14+ name: string,
15+ tier?: Plugin['tier'],
16+): Plugin => ({
17+ name,
18+ tier,
19+ register(on) {
20+ on('session.start', async ($, e, next) => {
21+ await $.env
22+ .set('NODE_OPTIONS', undefined)
23+ .catch((error: unknown) => $.ui.log(String(error)))
24+
25+ return next(e)
26+ })
27+ },
28+})
@@ -20,6 +20,51 @@
2020 ).toEqual([true, true, true, true, true, false, false])
2121 })
2222
23+ test("a setting's ask and a host's hold over an allow and a rewording", () => {
24+ expect(
25+ [Fixtures.SETTING_ASK, Fixtures.HOST_ASK].map(held => [
26+ Hooks.holdsOver(held, Fixtures.ALLOWED),
27+ Hooks.holdsOver(held, Fixtures.ASKED),
28+ Hooks.holdsOver(held, { ...held }),
29+ ]),
30+ ).toEqual([
31+ [true, true, false],
32+ [true, true, false],
33+ ])
34+ })
35+
36+ test('a setting or decider the answer itself names holds nothing', () => {
37+ expect(
38+ Hooks.holdsOver(Fixtures.ASKED, {
39+ ...Fixtures.ALLOWED,
40+ setting: 'permissions.blockReadsOutsideWorkingDirectories',
41+ decider: 'host',
42+ }),
43+ ).toBe(false)
44+ })
45+
46+ test('an ask is named for its hook, then its setting, then its host', () => {
47+ expect(
48+ [
49+ { ...Fixtures.HOST_ASK, ...Fixtures.SETTING_ASK, ...Fixtures.HOOK_ASK },
50+ { ...Fixtures.HOST_ASK, ...Fixtures.SETTING_ASK },
51+ Fixtures.HOST_ASK,
52+ ].map(held => Hooks.heldNotice('easy', 'Bash', held, 'lift')),
53+ ).toEqual(
54+ [
55+ "a PreToolUse hook's ask from a Bash call; the PreToolUse hook's ask",
56+ "a permissions.blockReadsOutsideWorkingDirectories setting's ask " +
57+ 'from a Bash call; the ' +
58+ "permissions.blockReadsOutsideWorkingDirectories setting's ask",
59+ "a host's ask from a Bash call; the host's ask",
60+ ].map(
61+ middle =>
62+ `easy tried to lift ${middle} holds over the plugins you install ` +
63+ '(allowModsToOverrideDenyRules)',
64+ ),
65+ )
66+ })
67+
2368 test('over the same decision reworded; a stricter answer stands', () => {
2469 expect(
2570 [
@@ -811,6 +811,55 @@
811811 )
812812
813813 test(
814+ "the read block's ask on a shell read holds over their allow",
815+ { plugins: [Fixtures.allowing('easy')] },
816+ async ($, on) => {
817+ on('settings.read', () => ({ value: Fixtures.MANAGED_POLICY }))
818+
819+ const lines = Fixtures.logged(on)
820+
821+ Fixtures.checksAnswered(on, Fixtures.SETTING_ASK)
822+
823+ expect(await $.tool.check(Fixtures.CHECKED)).toEqual(Fixtures.SETTING_ASK)
824+
825+ expect(lines).toEqual([
826+ `transcript: ${Hooks.heldNotice('easy', 'Bash', Fixtures.SETTING_ASK, 'lift')}`,
827+ ])
828+ },
829+ )
830+
831+ test(
832+ "the mode's own ask, where a host answers it, holds over their allow",
833+ { plugins: [Fixtures.allowing('easy')] },
834+ async ($, on) => {
835+ on('settings.read', () => ({ value: Fixtures.MANAGED_POLICY }))
836+
837+ const lines = Fixtures.logged(on)
838+
839+ Fixtures.checksAnswered(on, Fixtures.HOST_ASK)
840+
841+ expect(await $.tool.check(Fixtures.CHECKED)).toEqual(Fixtures.HOST_ASK)
842+
843+ expect(lines).toEqual([
844+ `transcript: ${Hooks.heldNotice('easy', 'Bash', Fixtures.HOST_ASK, 'lift')}`,
845+ ])
846+ },
847+ )
848+
849+ test(
850+ "an organization plugin's allow over a host's ask stands",
851+ { plugins: [Fixtures.allowing('corp', 'append')] },
852+ async ($, on) => {
853+ on('settings.read', () => ({ value: Fixtures.MANAGED_POLICY }))
854+ Fixtures.checksAnswered(on, Fixtures.HOST_ASK)
855+
856+ expect(await $.tool.check(Fixtures.CHECKED)).toEqual({
857+ decision: 'allow',
858+ })
859+ },
860+ )
861+
862+ test(
814863 "a classic hook's deny holds over their allow",
815864 { plugins: [Fixtures.allowing('easy')] },
816865 async ($, on) => {
@@ -1840,6 +1889,15 @@
18401889 ).toEqual([true, false, false, true])
18411890 })
18421891
1892+ test("a server named corp does not claim a corp-tools server's tools", () => {
1893+ expect(
1894+ Hooks.isOrgTool(
1895+ { managedMcpServers: { corp: {} } },
1896+ 'mcp__corp-tools__x',
1897+ ),
1898+ ).toBe(false)
1899+ })
1900+
18431901 test(
18441902 'a compaction is not theirs to answer',
18451903 { plugins: [Fixtures.compacting] },
@@ -2187,7 +2245,169 @@
21872245 ).toEqual([true, true, true, true, true, true, true, true, true])
21882246 })
21892247
2248+ test('every variable that can load code into a child is pinned', () => {
2249+ expect(
2250+ [
2251+ 'LD_PRELOAD',
2252+ 'LD_LIBRARY_PATH',
2253+ 'LD_AUDIT',
2254+ 'DYLD_INSERT_LIBRARIES',
2255+ 'DYLD_LIBRARY_PATH',
2256+ 'DYLD_FRAMEWORK_PATH',
2257+ 'BASH_ENV',
2258+ 'ENV',
2259+ 'ZDOTDIR',
2260+ 'NODE_OPTIONS',
2261+ 'ld_preload',
2262+ 'PATH',
2263+ 'HOME',
2264+ ].map(name => Hooks.isPinnedVariable({}, name)),
2265+ ).toEqual([
2266+ true,
2267+ true,
2268+ true,
2269+ true,
2270+ true,
2271+ true,
2272+ true,
2273+ true,
2274+ true,
2275+ true,
2276+ true,
2277+ false,
2278+ false,
2279+ ])
2280+ })
2281+
21902282 test(
2283+ "LD_PRELOAD is not a person's plugin's to set",
2284+ {
2285+ plugins: [
2286+ Fixtures.settingLdPreload('suite', 'prepend'),
2287+ Fixtures.settingLdPreload('mine'),
2288+ Fixtures.settingLdPreload('bundled', 'builtin'),
2289+ ],
2290+ },
2291+ async ($, on) => {
2292+ on('settings.read', () => ({ value: Fixtures.MANAGED_POLICY }))
2293+
2294+ const lines = Fixtures.logged(on)
2295+ const set = Fixtures.variablesSet(on)
2296+
2297+ await $.session.start(Fixtures.SESSION)
2298+
2299+ expect(set).toEqual([
2300+ 'suite LD_PRELOAD=mine',
2301+ 'bundled LD_PRELOAD=mine',
2302+ ])
2303+ expect(lines).toHaveLength(1)
2304+ expect(lines[0]).toContain(Hooks.pinnedVariableRefusal('LD_PRELOAD'))
2305+ },
2306+ )
2307+
2308+ test(
2309+ "DYLD_INSERT_LIBRARIES is not a person's plugin's to set",
2310+ {
2311+ plugins: [
2312+ Fixtures.settingDyldInsert('suite', 'prepend'),
2313+ Fixtures.settingDyldInsert('mine'),
2314+ Fixtures.settingDyldInsert('bundled', 'builtin'),
2315+ ],
2316+ },
2317+ async ($, on) => {
2318+ on('settings.read', () => ({ value: Fixtures.MANAGED_POLICY }))
2319+
2320+ const lines = Fixtures.logged(on)
2321+ const set = Fixtures.variablesSet(on)
2322+
2323+ await $.session.start(Fixtures.SESSION)
2324+
2325+ expect(set).toEqual([
2326+ 'suite DYLD_INSERT_LIBRARIES=mine',
2327+ 'bundled DYLD_INSERT_LIBRARIES=mine',
2328+ ])
2329+ expect(lines).toHaveLength(1)
2330+ expect(lines[0]).toContain(
2331+ Hooks.pinnedVariableRefusal('DYLD_INSERT_LIBRARIES'),
2332+ )
2333+ },
2334+ )
2335+
2336+ test(
2337+ "BASH_ENV is not a person's plugin's to set",
2338+ {
2339+ plugins: [
2340+ Fixtures.settingBashEnv('suite', 'prepend'),
2341+ Fixtures.settingBashEnv('mine'),
2342+ Fixtures.settingBashEnv('bundled', 'builtin'),
2343+ ],
2344+ },
2345+ async ($, on) => {
2346+ on('settings.read', () => ({ value: Fixtures.MANAGED_POLICY }))
2347+
2348+ const lines = Fixtures.logged(on)
2349+ const set = Fixtures.variablesSet(on)
2350+
2351+ await $.session.start(Fixtures.SESSION)
2352+
2353+ expect(set).toEqual([
2354+ 'suite BASH_ENV=mine',
2355+ 'bundled BASH_ENV=mine',
2356+ ])
2357+ expect(lines).toHaveLength(1)
2358+ expect(lines[0]).toContain(Hooks.pinnedVariableRefusal('BASH_ENV'))
2359+ },
2360+ )
2361+
2362+ test(
2363+ "NODE_OPTIONS is not a person's plugin's to set",
2364+ {
2365+ plugins: [
2366+ Fixtures.settingNodeOptions('suite', 'prepend'),
2367+ Fixtures.settingNodeOptions('mine'),
2368+ Fixtures.settingNodeOptions('bundled', 'builtin'),
2369+ ],
2370+ },
2371+ async ($, on) => {
2372+ on('settings.read', () => ({ value: Fixtures.MANAGED_POLICY }))
2373+
2374+ const lines = Fixtures.logged(on)
2375+ const set = Fixtures.variablesSet(on)
2376+
2377+ await $.session.start(Fixtures.SESSION)
2378+
2379+ expect(set).toEqual([
2380+ 'suite NODE_OPTIONS=mine',
2381+ 'bundled NODE_OPTIONS=mine',
2382+ ])
2383+ expect(lines).toHaveLength(1)
2384+ expect(lines[0]).toContain(Hooks.pinnedVariableRefusal('NODE_OPTIONS'))
2385+ },
2386+ )
2387+
2388+ test(
2389+ 'nor is NODE_OPTIONS theirs to unset',
2390+ {
2391+ plugins: [
2392+ Fixtures.unsettingNodeOptions('suite', 'prepend'),
2393+ Fixtures.unsettingNodeOptions('mine'),
2394+ ],
2395+ },
2396+ async ($, on) => {
2397+ on('settings.read', () => ({ value: Fixtures.MANAGED_POLICY }))
2398+
2399+ const lines = Fixtures.logged(on)
2400+ const set = Fixtures.variablesSet(on)
2401+
2402+ await $.session.start(Fixtures.SESSION)
2403+
2404+ expect(set).toEqual(['suite NODE_OPTIONS=undefined'])
2405+ expect(lines).toHaveLength(1)
2406+ expect(lines[0]).toContain(Hooks.pinnedVariableRefusal('NODE_OPTIONS'))
2407+ },
2408+ )
2409+
2410+ test(
21912411 'a listed id that is one at-sign refuses every registration of theirs',
21922412 {
21932413 plugins: [Fixtures.registering('mine'), Fixtures.registering('other')],
Large file, diff not shown.