Skip to content

Commit 16da1ec

Browse files
authored
sec-default: a settings deny rule holds over an allow or ask from a plugin the person installed (#98080)
* sec-default: a settings deny rule holds over an allow or ask from a plugin the person installed * sec-default: the tool.check test plugins carry their verdicts in their own bodies * sec-default: a user-tier link counts as loosening only when it answers looser than it was handed * sec-default: the README says where the line lands in a plain -p run * sec-default: a batch listed under prepend may hold a person's plugin, and the line says lift * sec-default: the README and one doc comment say what the batch fix changed
1 parent 0d7f14d commit 16da1ec

43 files changed

Lines changed: 1045 additions & 11 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎mods/README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ source, published as it is built into the binary.
77

88
| Mod | What it does | Seated |
99
| --- | --- | --- |
10-
| [`sec-default`](sec-default) | Keeps an organization's classic hooks, prompt content, managed settings and tool policy out of reach of the plugins a person installs; adds no policy of its own. | Outermost, on a machine with managed settings or for a Team or Enterprise organization, unless managed `prependPlugins` says otherwise |
10+
| [`sec-default`](sec-default) | Keeps an organization's classic hooks, prompt content, managed settings, tool policy and deny rules out of reach of the plugins a person installs; adds no policy of its own. | Outermost, on a machine with managed settings or for a Team or Enterprise organization, unless managed `prependPlugins` says otherwise |
1111
| [`diff`](diff) | `/diff`: the session's uncommitted changes in a pane beside the transcript, file by file with their hunks, refreshed as Claude edits files and runs commands. | Built in |
1212
| [`telemetry`](telemetry) | Hooks `$.telemetry`'s two events (`log`, `mark`), adding the noun in the `engine.create` fold where the engine has none, so a built-in plugin can record an event as a first-party analytics row, sent in batches; refuses installed plugins; sends nothing wherever Claude Code's analytics are off. | Built in |
1313
| [`agents-md`](agents-md) | `AGENTS.md` as project instructions, by one option: loaded where the project has no `CLAUDE.md` of its own (`claude-md-or-agents-md`, the default) or beside it (`claude-md-and-agents-md`), placed and framed exactly as the engine places `CLAUDE.md`, nested ones on a `Read`; or the project's and the person's instruction files dropped and the organization's kept (`managed-only`); or `CLAUDE.md` alone, as the engine reads it (`claude-md`). | Built in |

‎mods/sec-default/README.md‎

Lines changed: 89 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,11 @@ The security default for organizations. Function hooks give every plugin a
44
say on every event, in chain order, and the plugins a person installs sit
55
in the user tier, beneath the organization's prepend tier and above its
66
append tier. Some of what an organization sets today (its classic hooks,
7-
its managed CLAUDE.md and rules, its settings, its MCP allowlist) was never
8-
within a person's reach before function hooks; seated outermost, this
9-
plugin keeps exactly those out of the user tier's reach and adds no policy
10-
of its own. Everything else passes through untouched.
7+
its managed CLAUDE.md and rules, its settings, its MCP allowlist, the deny
8+
rules in force on its machines) was never within a person's reach before
9+
function hooks; seated outermost, this plugin keeps exactly those out of the
10+
user tier's reach and adds no policy of its own. Everything else passes
11+
through untouched.
1112

1213
It has three moves and nothing else: continue past the user tier
1314
(`next.to(e, "append")`), refuse a user-tier caller or module by name
@@ -30,12 +31,13 @@ settings it decides by.
3031
| `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. |
3132
| `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. |
3233
| `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. |
34+
| `tool.check` | A deny that a settings rule decided holds over the user tier: when a person's plugin loosened the verdict it was handed, the dispatch is run again past the user tier, and if that verdict is a deny naming its rule, it is the answer. See [Deny rules hold](#deny-rules-hold). Every other verdict passes as the chain left it. |
3335
| `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. |
34-
| 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`. |
36+
| everything else | Passes: `prompt.submit`, `turn.*`, `tool.call`, `command.run`, `command.register`, `session.*`, `ui.*`, `fs.*`, `http.fetch`, `process.run`, `store.*`, `clock.*`, `model.*`, `mcp.call`, `audio.*`, `agent.list`, `engine.create`. |
3537

3638
## Options an administrator sets
3739

38-
One, in managed settings, under this plugin's own `pluginConfigs` entry,
40+
Two, in managed settings, under this plugin's own `pluginConfigs` entry,
3941
keyed by the id the CLI builds the plugin in under (only this spelling of the
4042
id is read):
4143

@@ -82,17 +84,95 @@ not loaded. Settings hooks, status lines and `/goal` are not touched by it.
8284
- `claude plugin test` is not covered: it runs a mod's tests in an engine of
8385
their own and loads nothing into a session.
8486

87+
`allowModsToOverrideDenyRules`: the plugins a person installs may answer
88+
over a settings deny rule on `tool.check`, as they could before this plugin
89+
held deny rules. Off unless it is the literal `true`; an option that reads
90+
as unset leaves deny rules holding. See [Deny rules hold](#deny-rules-hold).
91+
8592
## What it hooks
8693

8794
`classic.*`, `prompt.section`, `prompt.context`, `skill.prompt`,
8895
`attribution.text`, `settings.read`, `tool.describe`, `command.describe`,
89-
`agent.offer`, `agent.spawn`, `tool.register`, `tool.list`,
96+
`agent.offer`, `agent.spawn`, `tool.register`, `tool.list`, `tool.check`,
9097
`plugin.register`.
9198

99+
Hooking `tool.check` has a cost: the engine raises that event only when some
100+
loaded plugin hooks it, so where this plugin is seated every tool call now
101+
runs the `tool.check` chain, where before only a session with such a plugin
102+
did.
103+
92104
## What it calls on `$`
93105

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.
106+
`settings.read`, and `ui.log`: to the debug log, and for the one line a
107+
person reads when a deny rule held over a plugin of theirs. It continues to
108+
the `append` tier with `next.to`, which only a plugin in a managed tier may
109+
do.
110+
111+
## Deny rules hold
112+
113+
On `tool.check` any hook may answer any verdict, so a plugin a person
114+
installs to stop the permission prompts (`() => ({ decision: "allow" })`)
115+
would also lift a deny rule, a managed one included. Where this plugin is
116+
seated it does not:
117+
118+
- The hook first runs the chain as it is. If the answer is a deny, or no
119+
link that may hold a person's plugin answered more permissively than the
120+
verdict handed up to it, the answer passes: nothing of the person's
121+
loosened anything, and a plugin that only listens adds no run of its own.
122+
This is read off `next.trace`, whose tiers the engine pins: a link that never
123+
called `next` is measured against a deny, and since the engine lists
124+
neighbouring plugins that share a worker as one batch under its first
125+
member's tier, a `prepend` entry beneath this plugin counts as well as a
126+
`user` one. Whether a person's plugin did the loosening is never settled
127+
here, only by the next step.
128+
- Otherwise it runs the dispatch once more with the user tier left out
129+
(`next.to(e, "append")`). That verdict never passed through a person's
130+
plugin, so neither the decision nor the rule it names can have been
131+
rewritten or erased, and a plugin that answered without calling `next`
132+
changes nothing: the rules are evaluated in this run. The two runs differ
133+
by the user tier alone, so a deny here that names its rule is a deny rule
134+
the user tier loosened, and it is returned in place of the chain's answer.
135+
- Any deny rule counts, whatever settings file it came from: a verdict
136+
carries the rule as written, never where it was read from. A deny that
137+
names no rule (a settings hook's, a tool's own check) is not held.
138+
- An organization's plugin (prepend or append) or a built-in that allows
139+
over a deny rule takes part in both runs, so its answer stands (a prepended
140+
one that loosens is what brings the second run about, so its hooks run
141+
twice on such a call). An ask
142+
that a person's plugin turns into an allow, with no deny rule behind it,
143+
stands: that is what such a plugin is for.
144+
- `tool.check` pins the question (`tool`, `input`, `tool_use_id`), so no hook
145+
can have the rules evaluated on one command and another run; a rewrite
146+
belongs to `tool.call`, which runs before any of this.
147+
- The person is told once for each name in a session, in the transcript
148+
and the debug log: `<plugin> tried to lift a deny rule in your settings
149+
from a <tool> call (<rule>); the deny rule holds over the plugins you
150+
install (allowModsToOverrideDenyRules)`. Plugins the engine ran as one
151+
batch are named together, as it names them (`audit+easy`). A plain `-p`
152+
run has it in the debug log alone; the call is still denied with the
153+
rule's own message.
154+
- If the hook itself fails, its `.catch` answers from the one run it can
155+
read: a deny stands; a verdict no plugin of the person's loosened stands;
156+
one they loosened, or a run that rejected, is refused, since the deny
157+
rules were never consulted.
158+
159+
An organization that wants the plugins its people install to override deny
160+
rules says so in managed settings, under this plugin's own options:
161+
162+
```json
163+
{
164+
"pluginConfigs": {
165+
"cc-plugin-sec-default@builtin": {
166+
"options": { "allowModsToOverrideDenyRules": true }
167+
}
168+
}
169+
}
170+
```
171+
172+
Only the managed source is read (`$.settings.read({ source: "policy" })`),
173+
so the same key in a person's, a project's or a local settings file, or in
174+
`--settings`, is never consulted; only the literal `true` counts, and a
175+
policy that cannot be read leaves deny rules holding.
96176

97177
## Where it is seated
98178

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import type { EventResult, TraceEntry } from 'claude-code'
2+
3+
import Verdicts from './verdicts'
4+
5+
/**
6+
* What the `tool.check` hook's failure handler answers from the one run it
7+
* can read, the failed hook's last: that run's verdict, or a refusal.
8+
*
9+
* A deny stands, and so does a verdict no link that may hold a person's
10+
* plugin loosened. A loosened one, or none at all, met no deny rule.
11+
*
12+
* @param last what that run settled on; undefined when it rejected
13+
* @param trace that run's `next.trace`
14+
* @returns the verdict the handler returns
15+
*/
16+
export function caughtAnswer(
17+
last: EventResult<'tool.check'> | undefined,
18+
trace: readonly TraceEntry<'tool.check'>[],
19+
) {
20+
const isVouched =
21+
last !== undefined &&
22+
(last.decision === 'deny' || Verdicts.loosenedByUsers(trace).length === 0)
23+
24+
return isVouched ? last : Verdicts.UNCHECKED_DENY
25+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
/**
2+
* What a person reads, once for each plugin in a session, when a plugin they
3+
* installed answered allow or ask over a deny rule in their settings.
4+
*
5+
* It names the option an administrator sets to let such plugins override.
6+
*
7+
* @param plugin the plugin's name, or its batch's, as the trace names it
8+
* @param tool the tool the call named
9+
* @param rule the deny rule that decided, as written
10+
* @returns the line
11+
*/
12+
export const heldNotice = (plugin: string, tool: string, rule: string) =>
13+
`${plugin} tried to lift a deny rule in your settings from a ${tool} ` +
14+
`call (${rule}); the deny rule holds over the plugins you install ` +
15+
'(allowModsToOverrideDenyRules)'
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export * from './caught-answer.js'
2+
export * from './held-notice.js'
3+
export * from './verdicts'
4+
5+
export * as default from '.'
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
export * from './is-rule-deny.js'
2+
export * from './loosened-by-users.js'
3+
export * from './ranking'
4+
export * from './types'
5+
export * from './unchecked-deny.js'
6+
7+
export * as default from '.'
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import type { EventResult } from 'claude-code'
2+
3+
import type { RuleDeny } from './types'
4+
5+
/**
6+
* Whether a `tool.check` verdict is a deny that a settings rule decided: the
7+
* one verdict the plugins a person installs may not loosen.
8+
*
9+
* Any deny rule counts, whatever settings file it came from: a verdict
10+
* carries the rule as written and never where it was read from.
11+
*
12+
* @param verdict what a run of the chain settled on
13+
* @returns true for a deny that names the rule behind it
14+
*/
15+
export const isRuleDeny = (
16+
verdict: EventResult<'tool.check'>,
17+
): verdict is RuleDeny =>
18+
verdict.decision === 'deny' && verdict.rule !== undefined
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import type { TraceEntry } from 'claude-code'
2+
3+
import Ranking from './ranking'
4+
5+
/**
6+
* The links of one run of `tool.check` that may hold a plugin a person
7+
* installed and that answered more permissively than they were handed.
8+
*
9+
* Read off `next.trace`, whose `tier` the engine pins. A batch is named as
10+
* the engine names it, its members joined; whether a person's plugin did
11+
* the loosening is settled by the run past the user tier, never here.
12+
*
13+
* @param trace what settled beneath the hook on its latest `next` call
14+
* @returns their names, nearest the caller first; none when none loosened
15+
*/
16+
export const loosenedByUsers = (
17+
trace: readonly TraceEntry<'tool.check'>[],
18+
): readonly string[] =>
19+
trace
20+
.filter(
21+
(link, at) =>
22+
Ranking.TIERS_HOLDING_USERS.includes(link.tier) &&
23+
Ranking.isLooser(link.returned, Ranking.handedTo(trace, at)),
24+
)
25+
.map(link => link.plugin)
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import type { TraceEntry } from 'claude-code'
2+
3+
/**
4+
* The verdict handed up to one link of a run: what the nearest link beneath
5+
* it that settled on anything settled on.
6+
*
7+
* Undefined for a link that answered without calling `next`: the trace ends
8+
* short of the engine there, and nothing beneath it ran.
9+
*
10+
* @param trace a run as `next.trace` lists it, nearest the caller first
11+
* @param at the link's place in that list
12+
* @returns the verdict, or undefined when nothing settled beneath the link
13+
*/
14+
export const handedTo = (
15+
trace: readonly TraceEntry<'tool.check'>[],
16+
at: number,
17+
) => trace.slice(at + 1).find(link => link.returned !== undefined)?.returned
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
export * from './handed-to.js'
2+
export * from './is-looser.js'
3+
export * from './leniency'
4+
export * from './tiers-holding-users'
5+
6+
export * as default from '.'

0 commit comments

Comments
 (0)