Skip to content

Commit 0d7f14d

Browse files
authored
sec-default: a managed option, allowManagedModsOnly, keeps the mods a person installs from loading (#98083)
1 parent dec92bc commit 0d7f14d

25 files changed

Lines changed: 463 additions & 16 deletions

‎mods/sec-default/.claude-plugin/plugin.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "sec-default",
33
"version": "0.1.0",
4-
"description": "Security default for organizations: seated outermost, it keeps the organization's classic hooks, prompt content, settings and tool policy out of reach of the plugins a person installs, and adds no policy of its own.",
4+
"description": "Security default for organizations: seated outermost, it keeps the organization's classic hooks, prompt content, settings and tool policy out of reach of the plugins a person installs, and adds no policy of its own; one managed option, allowManagedModsOnly, limits mods to the organization's.",
55
"author": {
66
"name": "Anthropic"
77
}

‎mods/sec-default/README.md‎

Lines changed: 60 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,12 @@ plugin keeps exactly those out of the user tier's reach and adds no policy
1010
of its own. Everything else passes through untouched.
1111

1212
It has three moves and nothing else: continue past the user tier
13-
(`next.to(e, "append")`), refuse a user-tier caller by name (`{ deny }`
14-
when `next.origin.tier` is `user`), or pass (`next(e)`). A subject's
15-
provenance is the event's pinned `e.provider`; policy is read through
16-
`$.settings.read({ source: "policy" })`, one read serving a burst; both
17-
fail closed, so an unreadable policy counts as a policy in force.
13+
(`next.to(e, "append")`), refuse a user-tier caller or module by name
14+
(`{ deny }` when `next.origin.tier` is `user`, `{ refuse }` when a module's
15+
pinned `e.tier` is), or pass (`next(e)`). A subject's provenance is the
16+
event's pinned `e.provider`; policy is read through
17+
`$.settings.read({ source: "policy" })`, one read serving a burst of tool
18+
calls; both fail closed, so an unreadable policy counts as a policy in force.
1819

1920
`hooks/register.ts` is the module; `hooks/policy/` reads the managed
2021
settings it decides by.
@@ -29,18 +30,69 @@ settings it decides by.
2930
| `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. |
3031
| `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. |
3132
| `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. |
33+
| `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. |
3234
| everything else | Passes: `prompt.submit`, `turn.*`, `tool.call`, `tool.check`, `command.run`, `command.register`, `session.*`, `ui.*`, `fs.*`, `http.fetch`, `process.run`, `store.*`, `clock.*`, `model.*`, `mcp.call`, `audio.*`, `agent.list`, `engine.create`. |
3335

36+
## Options an administrator sets
37+
38+
One, in managed settings, under this plugin's own `pluginConfigs` entry,
39+
keyed by the id the CLI builds the plugin in under (only this spelling of the
40+
id is read):
41+
42+
```json
43+
{
44+
"pluginConfigs": {
45+
"cc-plugin-sec-default@builtin": {
46+
"options": { "allowManagedModsOnly": true }
47+
}
48+
}
49+
}
50+
```
51+
52+
`allowManagedModsOnly`: only the mods the organization deploys through
53+
managed settings, and the ones built into Claude Code, load. A hooks module
54+
a person installed, named with `--plugin-dir` or keeps in their mods folder
55+
is refused whenever it loads or reloads (one already running when the option
56+
is set keeps running until then), with one line that names it: `mods are
57+
limited to your organization's by policy (allowManagedModsOnly); <plugin>
58+
was not loaded`
59+
(in the debug log, and on screen where the session hot-reloads the mod's
60+
folder). A plain `-p` run has it in the debug log alone; the mod is still
61+
not loaded. Settings hooks, status lines and `/goal` are not touched by it.
62+
63+
- The decision reads the tier the CLI pins on the module and nothing the
64+
module says of itself: a person's copy carrying an organization mod's name
65+
is still in the `user` tier and is refused.
66+
- Refused means nothing of the module joins: no hook, no tool, no command.
67+
Its top-level code has run once by then, in the closed context every hooks
68+
module is evaluated in, with no call on `$` served.
69+
- Only managed settings are read (`$.settings.read({ source: "policy" })`):
70+
the same entry in a person's, a project's or a `--settings` file neither
71+
turns it on nor off. On unless absent or `false`, so a mistyped `"true"` or
72+
`1` still locks. A value the settings schema rejects (`null`, an object),
73+
here or in any other `pluginConfigs` entry, makes the CLI ignore the whole
74+
`pluginConfigs` key with a settings warning, and the option reads as unset.
75+
- It fails closed: when the read of managed settings is refused (a hook beneath
76+
denies it) or this plugin's hook fails, its `.catch` refuses the module,
77+
with the same line, and names the failure in the debug log; so a policy
78+
that cannot be read keeps every person's mod out at load. Where this plugin
79+
is not seated there is no such rule, and mods load as they do without it.
80+
- Where managed settings define `prependPlugins`, that list must name this
81+
plugin (next section) for the option to apply.
82+
- `claude plugin test` is not covered: it runs a mod's tests in an engine of
83+
their own and loads nothing into a session.
84+
3485
## What it hooks
3586

3687
`classic.*`, `prompt.section`, `prompt.context`, `skill.prompt`,
3788
`attribution.text`, `settings.read`, `tool.describe`, `command.describe`,
38-
`agent.offer`, `agent.spawn`, `tool.register`, `tool.list`.
89+
`agent.offer`, `agent.spawn`, `tool.register`, `tool.list`,
90+
`plugin.register`.
3991

4092
## What it calls on `$`
4193

42-
`settings.read`. It continues to the `append` tier with `next.to`, which
43-
only a plugin in a managed tier may do.
94+
`settings.read`, and `ui.log` to the debug log. It continues to the `append`
95+
tier with `next.to`, which only a plugin in a managed tier may do.
4496

4597
## Where it is seated
4698

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import type { HookFailure } from 'claude-code'
2+
3+
/**
4+
* The debug line for a `plugin.register` hook of this plugin that failed:
5+
* the module it was judging, how it failed, and what the failure said.
6+
*
7+
* @param name the judged plugin's name, as its own manifest gives it
8+
* @param error why the hook failed, as its `.catch` reads it
9+
* @returns the line
10+
*/
11+
export const admissionFailure = (name: string, error: HookFailure) =>
12+
`plugin.register hook failed judging ${name} (${error.kind}): ` +
13+
(error.message ?? 'no message')
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export * from './admission-failure.js'
2+
3+
export * as default from '.'

‎mods/sec-default/hooks/hooks.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -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, refuses a user-tier tool.register under an MCP allowlist, and restores the organization's tools in tool.list",
2+
"description": "Security default: from the outermost seat, continues past the user tier on the organization's classic hooks, prompt content, settings and subjects, refuses a user-tier tool.register under an MCP allowlist, restores the organization's tools in tool.list, and refuses a user-tier hooks module at plugin.register while managed settings set its allowManagedModsOnly option",
33
"modules": ["./register.ts"]
44
}

‎mods/sec-default/hooks/index.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
export * from './admission-failure'
2+
export * from './managed-mods-only-refusal'
13
export * from './past-users'
24
export * from './policy'
35
export * from './register.js'
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export * from './managed-mods-only-refusal.js'
2+
3+
export * as default from '.'
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
/**
2+
* What a person reads when managed policy keeps a mod of theirs out: the
3+
* rule, the option that set it, and the mod that was not loaded.
4+
*
5+
* @param name the refused plugin's name, as its own manifest gives it
6+
* @returns the refusal line
7+
*/
8+
export const managedModsOnlyRefusal = (name: string) =>
9+
"mods are limited to your organization's by policy " +
10+
`(allowManagedModsOnly); ${name} was not loaded`

‎mods/sec-default/hooks/policy/index.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
export * from './create-policy-memo'
22
export * from './decided-by-policy.js'
33
export * from './has-mcp-allowlist.js'
4+
export * from './is-managed-mods-only.js'
45
export * from './managed-tools-restored'
6+
export * from './own-option'
57
export * from './policy-memo-ms.js'
68
export * from './source.js'
79

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import type { Settings } from 'claude-code'
2+
3+
import { ownOption } from './own-option'
4+
5+
/**
6+
* Whether managed policy limits mods to the organization's: this plugin's
7+
* own option `allowManagedModsOnly` in managed settings.
8+
*
9+
* On unless absent or `false`: a value mistyped (`"true"`, `1`) still
10+
* locks, as the CLI's own managed locks read.
11+
*
12+
* @param policy the managed settings, as `$.settings.read` answers them
13+
* @returns true when the person's own mods are not to load
14+
*/
15+
export function isManagedModsOnly(policy: Settings) {
16+
const value = ownOption(policy, 'allowManagedModsOnly')
17+
18+
return value !== undefined && value !== false
19+
}

0 commit comments

Comments
 (0)