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
This commit is contained in:
Alice T'Poteat
2026-09-29 19:07:07 +00:00
committed by GitHub
parent 0d7f14dd35
commit 16da1ecd3f
43 changed files with 1045 additions and 11 deletions
+1 -1
View File
@@ -7,7 +7,7 @@ source, published as it is built into the binary.
| Mod | What it does | Seated |
| --- | --- | --- |
| [`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 |
| [`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 |
| [`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 |
| [`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 |
| [`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 |
+89 -9
View File
@@ -4,10 +4,11 @@ The security default for organizations. Function hooks give every plugin a
say on every event, in chain order, and the plugins a person installs sit
in the user tier, beneath the organization's prepend tier and above its
append tier. Some of what an organization sets today (its classic hooks,
its managed CLAUDE.md and rules, its settings, its MCP allowlist) was never
within a person's reach before function hooks; seated outermost, this
plugin keeps exactly those out of the user tier's reach and adds no policy
of its own. Everything else passes through untouched.
its managed CLAUDE.md and rules, its settings, its MCP allowlist, the deny
rules in force on its machines) was never within a person's reach before
function hooks; seated outermost, this plugin keeps exactly those out of the
user tier's reach and adds no policy of its own. Everything else passes
through untouched.
It has three moves and nothing else: continue past the user tier
(`next.to(e, "append")`), refuse a user-tier caller or module by name
@@ -30,12 +31,13 @@ settings it decides by.
| `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. |
| `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. |
| `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. |
| `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. |
| `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. |
| 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`. |
| 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`. |
## Options an administrator sets
One, in managed settings, under this plugin's own `pluginConfigs` entry,
Two, in managed settings, under this plugin's own `pluginConfigs` entry,
keyed by the id the CLI builds the plugin in under (only this spelling of the
id is read):
@@ -82,17 +84,95 @@ not loaded. Settings hooks, status lines and `/goal` are not touched by it.
- `claude plugin test` is not covered: it runs a mod's tests in an engine of
their own and loads nothing into a session.
`allowModsToOverrideDenyRules`: the plugins a person installs may answer
over a settings deny rule on `tool.check`, as they could before this plugin
held deny rules. Off unless it is the literal `true`; an option that reads
as unset leaves deny rules holding. See [Deny rules hold](#deny-rules-hold).
## What it hooks
`classic.*`, `prompt.section`, `prompt.context`, `skill.prompt`,
`attribution.text`, `settings.read`, `tool.describe`, `command.describe`,
`agent.offer`, `agent.spawn`, `tool.register`, `tool.list`,
`agent.offer`, `agent.spawn`, `tool.register`, `tool.list`, `tool.check`,
`plugin.register`.
Hooking `tool.check` has a cost: the engine raises that event only when some
loaded plugin hooks it, so where this plugin is seated every tool call now
runs the `tool.check` chain, where before only a session with such a plugin
did.
## What it calls on `$`
`settings.read`, and `ui.log` to the debug log. It continues to the `append`
tier with `next.to`, which only a plugin in a managed tier may do.
`settings.read`, and `ui.log`: to the debug log, and for the one line a
person reads when a deny rule held over a plugin of theirs. It continues to
the `append` tier with `next.to`, which only a plugin in a managed tier may
do.
## Deny rules hold
On `tool.check` any hook may answer any verdict, so a plugin a person
installs to stop the permission prompts (`() => ({ decision: "allow" })`)
would also lift a deny rule, a managed one included. Where this plugin is
seated it does not:
- The hook first runs the chain as it is. If the answer is a deny, or no
link that may hold a person's plugin answered more permissively than the
verdict handed up to it, the answer passes: nothing of the person's
loosened anything, and a plugin that only listens adds no run of its own.
This is read off `next.trace`, whose tiers the engine pins: a link that never
called `next` is measured against a deny, and since the engine lists
neighbouring plugins that share a worker as one batch under its first
member's tier, a `prepend` entry beneath this plugin counts as well as a
`user` one. Whether a person's plugin did the loosening is never settled
here, only by the next step.
- Otherwise it runs the dispatch once more with the user tier left out
(`next.to(e, "append")`). That verdict never passed through a person's
plugin, so neither the decision nor the rule it names can have been
rewritten or erased, and a plugin that answered without calling `next`
changes nothing: the rules are evaluated in this run. The two runs differ
by the user tier alone, so a deny here that names its rule is a deny rule
the user tier loosened, and it is returned in place of the chain's answer.
- Any deny rule counts, whatever settings file it came from: a verdict
carries the rule as written, never where it was read from. A deny that
names no rule (a settings hook's, a tool's own check) is not held.
- An organization's plugin (prepend or append) or a built-in that allows
over a deny rule takes part in both runs, so its answer stands (a prepended
one that loosens is what brings the second run about, so its hooks run
twice on such a call). An ask
that a person's plugin turns into an allow, with no deny rule behind it,
stands: that is what such a plugin is for.
- `tool.check` pins the question (`tool`, `input`, `tool_use_id`), so no hook
can have the rules evaluated on one command and another run; a rewrite
belongs to `tool.call`, which runs before any of this.
- The person is told once for each name in a session, in the transcript
and the debug log: `<plugin> tried to lift a deny rule in your settings
from a <tool> call (<rule>); the deny rule holds over the plugins you
install (allowModsToOverrideDenyRules)`. Plugins the engine ran as one
batch are named together, as it names them (`audit+easy`). A plain `-p`
run has it in the debug log alone; the call is still denied with the
rule's own message.
- If the hook itself fails, its `.catch` answers from the one run it can
read: a deny stands; a verdict no plugin of the person's loosened stands;
one they loosened, or a run that rejected, is refused, since the deny
rules were never consulted.
An organization that wants the plugins its people install to override deny
rules says so in managed settings, under this plugin's own options:
```json
{
"pluginConfigs": {
"cc-plugin-sec-default@builtin": {
"options": { "allowModsToOverrideDenyRules": true }
}
}
}
```
Only the managed source is read (`$.settings.read({ source: "policy" })`),
so the same key in a person's, a project's or a local settings file, or in
`--settings`, is never consulted; only the literal `true` counts, and a
policy that cannot be read leaves deny rules holding.
## Where it is seated
@@ -0,0 +1,25 @@
import type { EventResult, TraceEntry } from 'claude-code'
import Verdicts from './verdicts'
/**
* What the `tool.check` hook's failure handler answers from the one run it
* can read, the failed hook's last: that run's verdict, or a refusal.
*
* A deny stands, and so does a verdict no link that may hold a person's
* plugin loosened. A loosened one, or none at all, met no deny rule.
*
* @param last what that run settled on; undefined when it rejected
* @param trace that run's `next.trace`
* @returns the verdict the handler returns
*/
export function caughtAnswer(
last: EventResult<'tool.check'> | undefined,
trace: readonly TraceEntry<'tool.check'>[],
) {
const isVouched =
last !== undefined &&
(last.decision === 'deny' || Verdicts.loosenedByUsers(trace).length === 0)
return isVouched ? last : Verdicts.UNCHECKED_DENY
}
@@ -0,0 +1,15 @@
/**
* What a person reads, once for each plugin in a session, when a plugin they
* installed answered allow or ask over a deny rule in their settings.
*
* It names the option an administrator sets to let such plugins override.
*
* @param plugin the plugin's name, or its batch's, as the trace names it
* @param tool the tool the call named
* @param rule the deny rule that decided, as written
* @returns the line
*/
export const heldNotice = (plugin: string, tool: string, rule: string) =>
`${plugin} tried to lift a deny rule in your settings from a ${tool} ` +
`call (${rule}); the deny rule holds over the plugins you install ` +
'(allowModsToOverrideDenyRules)'
@@ -0,0 +1,5 @@
export * from './caught-answer.js'
export * from './held-notice.js'
export * from './verdicts'
export * as default from '.'
@@ -0,0 +1,7 @@
export * from './is-rule-deny.js'
export * from './loosened-by-users.js'
export * from './ranking'
export * from './types'
export * from './unchecked-deny.js'
export * as default from '.'
@@ -0,0 +1,18 @@
import type { EventResult } from 'claude-code'
import type { RuleDeny } from './types'
/**
* Whether a `tool.check` verdict is a deny that a settings rule decided: the
* one verdict the plugins a person installs may not loosen.
*
* Any deny rule counts, whatever settings file it came from: a verdict
* carries the rule as written and never where it was read from.
*
* @param verdict what a run of the chain settled on
* @returns true for a deny that names the rule behind it
*/
export const isRuleDeny = (
verdict: EventResult<'tool.check'>,
): verdict is RuleDeny =>
verdict.decision === 'deny' && verdict.rule !== undefined
@@ -0,0 +1,25 @@
import type { TraceEntry } from 'claude-code'
import Ranking from './ranking'
/**
* The links of one run of `tool.check` that may hold a plugin a person
* installed and that answered more permissively than they were handed.
*
* Read off `next.trace`, whose `tier` the engine pins. A batch is named as
* the engine names it, its members joined; whether a person's plugin did
* the loosening is settled by the run past the user tier, never here.
*
* @param trace what settled beneath the hook on its latest `next` call
* @returns their names, nearest the caller first; none when none loosened
*/
export const loosenedByUsers = (
trace: readonly TraceEntry<'tool.check'>[],
): readonly string[] =>
trace
.filter(
(link, at) =>
Ranking.TIERS_HOLDING_USERS.includes(link.tier) &&
Ranking.isLooser(link.returned, Ranking.handedTo(trace, at)),
)
.map(link => link.plugin)
@@ -0,0 +1,17 @@
import type { TraceEntry } from 'claude-code'
/**
* The verdict handed up to one link of a run: what the nearest link beneath
* it that settled on anything settled on.
*
* Undefined for a link that answered without calling `next`: the trace ends
* short of the engine there, and nothing beneath it ran.
*
* @param trace a run as `next.trace` lists it, nearest the caller first
* @param at the link's place in that list
* @returns the verdict, or undefined when nothing settled beneath the link
*/
export const handedTo = (
trace: readonly TraceEntry<'tool.check'>[],
at: number,
) => trace.slice(at + 1).find(link => link.returned !== undefined)?.returned
@@ -0,0 +1,6 @@
export * from './handed-to.js'
export * from './is-looser.js'
export * from './leniency'
export * from './tiers-holding-users'
export * as default from '.'
@@ -0,0 +1,20 @@
import type { EventResult } from 'claude-code'
import { LENIENCY } from './leniency'
/**
* Whether a link's own verdict is more permissive than the one handed up to
* it; with nothing handed up (it never called `next`), than a deny.
*
* A link that settled on nothing (skipped, rejected) loosened nothing.
*
* @param own what the link settled on
* @param handed what settled beneath it and was handed up, if anything did
* @returns true when the link loosened the verdict
*/
export const isLooser = (
own: EventResult<'tool.check'> | undefined,
handed: EventResult<'tool.check'> | undefined,
) =>
own !== undefined &&
LENIENCY[own.decision] > LENIENCY[handed?.decision ?? 'deny']
@@ -0,0 +1,3 @@
export * from './leniency.js'
export * as default from '.'
@@ -0,0 +1,5 @@
/**
* How permissive each `tool.check` verdict is, the refusal lowest: a link
* loosened a verdict when its own ranks above the one handed up to it.
*/
export const LENIENCY = Object.freeze({ deny: 0, ask: 1, allow: 2 })
@@ -0,0 +1,3 @@
export * from './tiers-holding-users.js'
export * as default from '.'
@@ -0,0 +1,12 @@
/**
* The tiers a trace entry can carry when a plugin a person installed ran in
* it: `user`, and `prepend` beneath this plugin's own seat.
*
* The engine runs neighbouring plugins that share a worker as one batch and
* lists the batch under its first member's tier, so an organization's
* prepended plugin can stand for a batch that holds a person's.
*/
export const TIERS_HOLDING_USERS: readonly unknown[] = Object.freeze([
'user',
'prepend',
])
@@ -0,0 +1,3 @@
export type * from './rule-deny.js'
export * as default from '.'
@@ -0,0 +1,9 @@
import type { EventResult } from 'claude-code'
/**
* A `tool.check` deny that names the settings rule behind it.
*/
export type RuleDeny = EventResult<'tool.check'> & {
readonly decision: 'deny'
readonly rule: string
}
@@ -0,0 +1,12 @@
import type { EventResult } from 'claude-code'
/**
* What the failure handler answers when a verdict was loosened, or never
* reached, and no deny rule check vouches for it: absent counts as deny.
*/
export const UNCHECKED_DENY: EventResult<'tool.check'> = Object.freeze({
decision: 'deny',
reason:
'the deny rules in your settings could not be checked for this call, ' +
'so it is refused',
})
+1 -1
View File
@@ -1,4 +1,4 @@
{
"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",
"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, holds a settings deny rule over a user-tier allow or ask on tool.check, and refuses a user-tier hooks module at plugin.register while managed settings set its allowManagedModsOnly option",
"modules": ["./register.ts"]
}
+1
View File
@@ -1,4 +1,5 @@
export * from './admission-failure'
export * from './held-verdict'
export * from './managed-mods-only-refusal'
export * from './past-users'
export * from './policy'
@@ -0,0 +1,16 @@
import type { Settings } from 'claude-code'
import { ownOption } from './own-option'
/**
* Whether deny rules hold over the plugins a person installs: they do unless
* managed policy sets `allowModsToOverrideDenyRules` to the literal `true`.
*
* A value mistyped (`"true"`, `1`) loosens nothing. Only the policy source
* is handed in, so a person's settings never reach this.
*
* @param policy the managed settings, as `$.settings.read` answers them
* @returns false only when the organization let a person's plugins override
*/
export const denyRulesHold = (policy: Settings) =>
ownOption(policy, 'allowModsToOverrideDenyRules') !== true
+1
View File
@@ -1,5 +1,6 @@
export * from './create-policy-memo'
export * from './decided-by-policy.js'
export * from './deny-rules-hold.js'
export * from './has-mcp-allowlist.js'
export * from './is-managed-mods-only.js'
export * from './managed-tools-restored'
+41
View File
@@ -1,6 +1,7 @@
import type { On } from 'claude-code'
import { admissionFailure } from './admission-failure'
import HeldVerdict from './held-verdict'
import { managedModsOnlyRefusal } from './managed-mods-only-refusal'
import { pastUsers } from './past-users'
import Policy from './policy'
@@ -18,6 +19,7 @@ import { TOOL_REGISTER_REFUSAL } from './tool-register-refusal'
*/
export function register(on: On) {
const readPolicy = Policy.createPolicyMemo(Policy.POLICY_MEMO_MS)
const told = new Set<string>()
on('classic.*', ($, e, next) => next.to(e, 'append'))
@@ -61,6 +63,45 @@ export function register(on: On) {
),
)
on('tool.check', async ($, e, next) => {
const answer = await next(e)
const mods = HeldVerdict.loosenedByUsers(next.trace)
const shouldRecheck =
answer.decision !== 'deny' &&
mods.length > 0 &&
(await Policy.decidedByPolicy(
readPolicy(() => $.settings.read(Policy.SOURCE)),
Policy.denyRulesHold,
))
if (!shouldRecheck) {
return answer
}
const held = await next.to(e, 'append')
if (!HeldVerdict.isRuleDeny(held)) {
return answer
}
for (const mod of mods.filter(name => !told.has(name))) {
told.add(mod)
$.ui.log(HeldVerdict.heldNotice(mod, e.tool, held.rule))
}
return held
}).catch(async ($, e, next) => {
const last = await next(e).catch(() => undefined)
const shouldVouch = await Policy.decidedByPolicy(
readPolicy(() => $.settings.read(Policy.SOURCE)),
Policy.denyRulesHold,
)
return shouldVouch ? HeldVerdict.caughtAnswer(last, next.trace) : last
})
on('plugin.register', { tier: 'user' }, async ($, e, next) =>
Policy.isManagedModsOnly(await $.settings.read(Policy.SOURCE))
? { refuse: managedModsOnlyRefusal(e.name) }
+1
View File
@@ -28,6 +28,7 @@ export * from './session.js'
export * from './signing.js'
export * from './stripping.js'
export * from './subjects-echoed.js'
export * from './tool-check'
export * from './tool-described.js'
export * from './tools.js'
export * from './tools-command.js'
+6
View File
@@ -0,0 +1,6 @@
import type { EventResult } from 'claude-code'
/**
* What an auto-approve plugin answers every `tool.check`: allow.
*/
export const ALLOWED: EventResult<'tool.check'> = { decision: 'allow' }
+21
View File
@@ -0,0 +1,21 @@
import type { Plugin } from 'claude-code/testing'
/**
* A plugin that hears the verdict beneath it on `tool.check`, then allows
* the call whatever it heard: the person's own by default.
*
* @param name the plugin's name
* @param tier the tier it loads in
* @returns the plugin
*/
export const allowing = (name: string, tier?: Plugin['tier']): Plugin => ({
name,
tier,
register(on) {
on('tool.check', async ($, e, next) => {
await next(e)
return { decision: 'allow' }
})
},
})
+9
View File
@@ -0,0 +1,9 @@
import type { EventResult } from 'claude-code'
/**
* The engine's verdict when nothing allows or denies the call: ask.
*/
export const ASKED: EventResult<'tool.check'> = {
decision: 'ask',
reason: 'Bash needs approval',
}
+19
View File
@@ -0,0 +1,19 @@
import type { Plugin } from 'claude-code/testing'
/**
* A plugin the person installed that hears the verdict beneath it on
* `tool.check`, then puts the call to the person whatever it heard.
*
* @param name the plugin's name
* @returns the plugin
*/
export const asking = (name: string): Plugin => ({
name,
register(on) {
on('tool.check', async ($, e, next) => {
await next(e)
return { decision: 'ask', reason: 'Bash needs approval' }
})
},
})
@@ -0,0 +1,12 @@
import type { Plugin } from 'claude-code/testing'
/**
* A plugin the person installed that allows every `tool.check` without
* calling `next`, so nothing beneath it runs.
*/
export const blindAllowing: Plugin = {
name: 'blind',
register(on) {
on('tool.check', () => ({ decision: 'allow' }))
},
}
+9
View File
@@ -0,0 +1,9 @@
import type { Args } from 'claude-code'
/**
* The question the engine puts to `tool.check`: may Bash run `echo x`.
*/
export const CHECKED: Args<'tool.check'> = {
tool: 'Bash',
input: { command: 'echo x' },
}
@@ -0,0 +1,21 @@
import type { EventResult, On } from 'claude-code'
/**
* Answers every `tool.check` beneath the plugins with the verdict given, as
* the engine's own evaluation would, counting the evaluations.
*
* @param on the test's `on`
* @param verdict what the engine's evaluation decides
* @returns how many evaluations ran so far
*/
export function checksAnswered(on: On, verdict: EventResult<'tool.check'>) {
let evaluations = 0
on('tool.check', () => {
evaluations += 1
return verdict
})
return () => evaluations
}
+16
View File
@@ -0,0 +1,16 @@
import type { Plugin } from 'claude-code/testing'
/**
* A plugin the person installed that answers ask over what it heard and
* writes a rule of its own into the answer, as if settings held it.
*/
export const forging: Plugin = {
name: 'forging',
register(on) {
on('tool.check', async ($, e, next) => {
await next(e)
return { decision: 'ask', reason: 'fine by me', rule: 'Bash(ls *)' }
})
},
}
+17
View File
@@ -0,0 +1,17 @@
export * from './allowed.js'
export * from './allowing.js'
export * from './asked.js'
export * from './asking.js'
export * from './blind-allowing.js'
export * from './checked.js'
export * from './checks-answered.js'
export * from './forging.js'
export * from './link-of.js'
export * from './listening.js'
export * from './override-policy-of.js'
export * from './plain-deny.js'
export * from './rewriting.js'
export * from './rule-deny.js'
export * from './tightening.js'
export * as default from '.'
+27
View File
@@ -0,0 +1,27 @@
import type { EventResult, Tier, TraceEntry } from 'claude-code'
import { CHECKED } from './checked.js'
/**
* One settled link of a `tool.check` run as `next.trace` lists it: whose
* hook, in which tier, and what it settled on (nothing when it was skipped).
*
* @param plugin the hook's plugin
* @param tier the tier the engine pinned for it
* @param returned what the link settled on
* @returns the entry
*/
export const linkOf = (
plugin: string,
tier: Tier,
returned?: EventResult<'tool.check'>,
): TraceEntry<'tool.check'> => ({
index: 0,
plugin,
tier,
event: 'tool.check',
outcome: returned === undefined ? 'skipped' : 'returned',
ms: 0,
received: CHECKED,
returned,
})
+17
View File
@@ -0,0 +1,17 @@
import type { Plugin } from 'claude-code/testing'
/**
* A plugin that hears every `tool.check` and hands up the verdict beneath it
* as it is, as one that only logs would: the person's own by default.
*
* @param name the plugin's name
* @param tier the tier it loads in
* @returns the plugin
*/
export const listening = (name: string, tier?: Plugin['tier']): Plugin => ({
name,
tier,
register(on) {
on('tool.check', ($, e, next) => next(e))
},
})
@@ -0,0 +1,16 @@
import type { Settings } from 'claude-code'
/**
* Settings that carry this plugin's own `allowModsToOverrideDenyRules`
* option with the value given, where managed settings hold it.
*
* @param value what the option is set to
* @returns the settings
*/
export const overridePolicyOf = (value: unknown): Settings => ({
pluginConfigs: {
'cc-plugin-sec-default@builtin': {
options: { allowModsToOverrideDenyRules: value },
},
},
})
@@ -0,0 +1,10 @@
import type { EventResult } from 'claude-code'
/**
* A deny that no settings rule decided (a settings hook's, a tool's own
* check): it names no rule.
*/
export const PLAIN_DENY: EventResult<'tool.check'> = {
decision: 'deny',
reason: 'a PreToolUse hook refused it',
}
+16
View File
@@ -0,0 +1,16 @@
import type { Plugin } from 'claude-code/testing'
/**
* A plugin the person installed that asks beneath it about another command
* than the one being decided, then allows the call.
*/
export const rewriting: Plugin = {
name: 'rewriting',
register(on) {
on('tool.check', async ($, e, next) => {
await next({ ...e, input: { command: 'ls' } })
return { decision: 'allow' }
})
},
}
+11
View File
@@ -0,0 +1,11 @@
import type { EventResult } from 'claude-code'
/**
* The engine's verdict when a deny rule in some settings file matched the
* call: the deny, its sentence, and the rule as written.
*/
export const RULE_DENY: EventResult<'tool.check'> = {
decision: 'deny',
reason: 'Permission to use Bash has been denied.',
rule: 'Bash(echo *)',
}
@@ -0,0 +1,16 @@
import type { Plugin } from 'claude-code/testing'
/**
* A plugin the person installed that hears the verdict beneath it on
* `tool.check`, then refuses the call whatever it heard.
*/
export const tightening: Plugin = {
name: 'strict',
register(on) {
on('tool.check', async ($, e, next) => {
await next(e)
return { decision: 'deny', reason: 'a PreToolUse hook refused it' }
})
},
}
@@ -0,0 +1,80 @@
import { describe, expect, test, tier } from 'claude-code/testing'
import Hooks from '../hooks'
import Fixtures from './fixtures'
tier('prepend')
describe('held-verdict', () => {
test('a deny that names its rule is a rule deny; any other is not', () => {
expect(
[Fixtures.RULE_DENY, Fixtures.PLAIN_DENY, Fixtures.ASKED].map(
Hooks.isRuleDeny,
),
).toEqual([true, false, false])
})
test('a link of theirs is named when it answered looser than handed', () => {
expect(
Hooks.loosenedByUsers([
Fixtures.linkOf('listening', 'user', Fixtures.ALLOWED),
Fixtures.linkOf('easy', 'user', Fixtures.ALLOWED),
Fixtures.linkOf('engine', 'core', Fixtures.ASKED),
]),
).toEqual(['easy'])
})
test('one that never called next is measured against a deny', () => {
expect(
Hooks.loosenedByUsers([
Fixtures.linkOf('listening', 'user', Fixtures.ALLOWED),
Fixtures.linkOf('blind', 'user', Fixtures.ALLOWED),
]),
).toEqual(['blind'])
})
test('a tightening, a skipped link and links beneath theirs are not', () => {
expect(
Hooks.loosenedByUsers([
Fixtures.linkOf('strict', 'user', Fixtures.PLAIN_DENY),
Fixtures.linkOf('passed-over', 'user'),
Fixtures.linkOf('suite', 'append', Fixtures.ALLOWED),
Fixtures.linkOf('bundled', 'builtin', Fixtures.ALLOWED),
Fixtures.linkOf('engine', 'core', Fixtures.RULE_DENY),
]),
).toEqual([])
})
test('a batch listed under prepend may hold a plugin of theirs', () => {
expect(
Hooks.loosenedByUsers([
Fixtures.linkOf('audit+easy', 'prepend', Fixtures.ALLOWED),
Fixtures.linkOf('engine', 'core', Fixtures.RULE_DENY),
]),
).toEqual(['audit+easy'])
})
test('the handler keeps a deny, and a verdict none of theirs made', () => {
const pastUsers = [
Fixtures.linkOf('easy', 'user'),
Fixtures.linkOf('engine', 'core', Fixtures.ASKED),
]
expect([
Hooks.caughtAnswer(Fixtures.RULE_DENY, [
Fixtures.linkOf('easy', 'user', Fixtures.ALLOWED),
]),
Hooks.caughtAnswer(Fixtures.ASKED, pastUsers),
]).toEqual([Fixtures.RULE_DENY, Fixtures.ASKED])
})
test('the handler refuses a verdict they loosened, and no verdict', () => {
expect([
Hooks.caughtAnswer(Fixtures.ALLOWED, [
Fixtures.linkOf('easy', 'user', Fixtures.ALLOWED),
Fixtures.linkOf('engine', 'core', Fixtures.ASKED),
]),
Hooks.caughtAnswer(undefined, []),
]).toEqual([Hooks.UNCHECKED_DENY, Hooks.UNCHECKED_DENY])
})
})
@@ -0,0 +1,24 @@
import { describe, expect, test, tier } from 'claude-code/testing'
import Policy from '../../hooks/policy'
import Fixtures from '../fixtures'
tier('prepend')
describe('deny-rules-hold', () => {
test('deny rules hold unless the option is the literal true', () => {
expect(
[true, 'true', 1, false, undefined].map(value =>
Policy.denyRulesHold(Fixtures.overridePolicyOf(value)),
),
).toEqual([false, true, true, true, true])
})
test('settings with no entry for this plugin hold them', () => {
expect(
[Fixtures.MANAGED_POLICY, {}, { pluginConfigs: 'x' }].map(
Policy.denyRulesHold,
),
).toEqual([true, true, true])
})
})
+362
View File
@@ -455,4 +455,366 @@ describe('register', () => {
})
},
)
test(
'a deny rule holds over an allow from a plugin the person installed',
{ plugins: [Fixtures.allowing('easy')] },
async ($, on) => {
on('settings.read', () => ({ value: Fixtures.MANAGED_POLICY }))
const lines = Fixtures.logged(on)
Fixtures.checksAnswered(on, Fixtures.RULE_DENY)
expect(await $.tool.check(Fixtures.CHECKED)).toEqual(Fixtures.RULE_DENY)
expect(lines).toEqual([
`transcript: ${Hooks.heldNotice('easy', 'Bash', 'Bash(echo *)')}`,
])
},
)
test(
'two plugins of the person chained are each named once in a session',
{
plugins: [Fixtures.allowing('first'), Fixtures.asking('second')],
},
async ($, on) => {
on('settings.read', () => ({ value: Fixtures.MANAGED_POLICY }))
const lines = Fixtures.logged(on)
Fixtures.checksAnswered(on, Fixtures.RULE_DENY)
expect(await $.tool.check(Fixtures.CHECKED)).toEqual(Fixtures.RULE_DENY)
expect(await $.tool.check(Fixtures.CHECKED)).toEqual(Fixtures.RULE_DENY)
expect(lines.toSorted()).toEqual([
`transcript: ${Hooks.heldNotice('first', 'Bash', 'Bash(echo *)')}`,
`transcript: ${Hooks.heldNotice('second', 'Bash', 'Bash(echo *)')}`,
])
},
)
test(
'a deny rule turned into an ask by a plugin of the person holds too',
{ plugins: [Fixtures.asking('easy')] },
async ($, on) => {
on('settings.read', () => ({ value: Fixtures.MANAGED_POLICY }))
Fixtures.logged(on)
Fixtures.checksAnswered(on, Fixtures.RULE_DENY)
expect(await $.tool.check(Fixtures.CHECKED)).toEqual(Fixtures.RULE_DENY)
},
)
test(
"an organization plugin's allow over a deny rule stands, above or below",
{
plugins: [
Fixtures.allowing('suite', 'append'),
Fixtures.allowing('easy'),
],
},
async ($, on) => {
on('settings.read', () => ({ value: Fixtures.MANAGED_POLICY }))
const lines = Fixtures.logged(on)
Fixtures.checksAnswered(on, Fixtures.RULE_DENY)
expect(await $.tool.check(Fixtures.CHECKED)).toEqual(Fixtures.ALLOWED)
expect(lines).toEqual([])
},
)
test(
"a prepended organization plugin's allow over a deny rule stands",
{ plugins: [Fixtures.allowing('guard', 'prepend')] },
async ($, on) => {
on('settings.read', () => ({ value: Fixtures.MANAGED_POLICY }))
const lines = Fixtures.logged(on)
Fixtures.checksAnswered(on, Fixtures.RULE_DENY)
expect(await $.tool.check(Fixtures.CHECKED)).toEqual(Fixtures.ALLOWED)
expect(lines).toEqual([])
},
)
test(
"a built-in's allow over a deny rule stands",
{ plugins: [Fixtures.allowing('bundled', 'builtin')] },
async ($, on) => {
on('settings.read', () => ({ value: Fixtures.MANAGED_POLICY }))
const lines = Fixtures.logged(on)
Fixtures.checksAnswered(on, Fixtures.RULE_DENY)
expect(await $.tool.check(Fixtures.CHECKED)).toEqual(Fixtures.ALLOWED)
expect(lines).toEqual([])
},
)
test(
'an ask a plugin of the person allows, no deny rule behind it, stands',
{ plugins: [Fixtures.allowing('easy')] },
async ($, on) => {
on('settings.read', () => ({ value: Fixtures.MANAGED_POLICY }))
const lines = Fixtures.logged(on)
Fixtures.checksAnswered(on, Fixtures.ASKED)
expect(await $.tool.check(Fixtures.CHECKED)).toEqual(Fixtures.ALLOWED)
expect(lines).toEqual([])
},
)
test(
'a deny no rule decided is still theirs to answer over',
{ plugins: [Fixtures.allowing('easy')] },
async ($, on) => {
on('settings.read', () => ({ value: Fixtures.MANAGED_POLICY }))
const lines = Fixtures.logged(on)
Fixtures.checksAnswered(on, Fixtures.PLAIN_DENY)
expect(await $.tool.check(Fixtures.CHECKED)).toEqual(Fixtures.ALLOWED)
expect(lines).toEqual([])
},
)
test(
'a plugin of the person that tightens is heard, with one evaluation',
{ plugins: [Fixtures.tightening] },
async ($, on) => {
const reads = Fixtures.policyReads(on, Fixtures.MANAGED_POLICY)
const evaluations = Fixtures.checksAnswered(on, Fixtures.ASKED)
await $.tool.check(Fixtures.CHECKED)
const loaded = { reads: reads(), evaluations: evaluations() }
expect(await $.tool.check(Fixtures.CHECKED)).toEqual(Fixtures.PLAIN_DENY)
expect(
{
reads: reads() - loaded.reads,
evaluations: evaluations() - loaded.evaluations,
},
'once its plugins have loaded, a check reads no policy',
).toEqual({ reads: 0, evaluations: 1 })
},
)
test(
"a deny rule holds when an organization's plugin listens above theirs",
{
plugins: [
Fixtures.listening('audit', 'prepend'),
Fixtures.allowing('easy'),
],
},
async ($, on) => {
on('settings.read', () => ({ value: Fixtures.MANAGED_POLICY }))
const lines = Fixtures.logged(on)
Fixtures.checksAnswered(on, Fixtures.RULE_DENY)
expect(await $.tool.check(Fixtures.CHECKED)).toEqual(Fixtures.RULE_DENY)
expect(
lines.map(line => line.includes('easy')),
'one line, naming the plugin of theirs, alone or in its batch',
).toEqual([true])
},
)
test(
'a plugin of the person that only listens costs no second evaluation',
{ plugins: [Fixtures.listening('listening')] },
async ($, on) => {
const reads = Fixtures.policyReads(on, Fixtures.MANAGED_POLICY)
const evaluations = Fixtures.checksAnswered(on, Fixtures.ASKED)
await $.tool.check(Fixtures.CHECKED)
const loaded = { reads: reads(), evaluations: evaluations() }
expect(await $.tool.check(Fixtures.CHECKED)).toEqual(Fixtures.ASKED)
expect(
{
reads: reads() - loaded.reads,
evaluations: evaluations() - loaded.evaluations,
},
'once its plugins have loaded, a check reads no policy',
).toEqual({ reads: 0, evaluations: 1 })
},
)
test('with none of their plugins the verdict passes once', async ($, on) => {
const reads = Fixtures.policyReads(on, Fixtures.MANAGED_POLICY)
const evaluations = Fixtures.checksAnswered(on, Fixtures.RULE_DENY)
expect(await $.tool.check(Fixtures.CHECKED)).toEqual(Fixtures.RULE_DENY)
expect({ reads: reads(), evaluations: evaluations() }).toEqual({
reads: 0,
evaluations: 1,
})
})
test(
'an allow that never called next meets the deny rule all the same',
{ plugins: [Fixtures.blindAllowing] },
async ($, on) => {
on('settings.read', () => ({ value: Fixtures.MANAGED_POLICY }))
const lines = Fixtures.logged(on)
const evaluations = Fixtures.checksAnswered(on, Fixtures.RULE_DENY)
expect(await $.tool.check(Fixtures.CHECKED)).toEqual(Fixtures.RULE_DENY)
expect({ lines, evaluations: evaluations() }).toEqual({
lines: [
`transcript: ${Hooks.heldNotice('blind', 'Bash', 'Bash(echo *)')}`,
],
evaluations: 1,
})
},
)
test(
'a rule a plugin of the person writes into its answer is never read',
{ plugins: [Fixtures.forging] },
async ($, on) => {
on('settings.read', () => ({ value: Fixtures.MANAGED_POLICY }))
const lines = Fixtures.logged(on)
Fixtures.checksAnswered(on, Fixtures.RULE_DENY)
expect(await $.tool.check(Fixtures.CHECKED)).toEqual(Fixtures.RULE_DENY)
expect(lines).toEqual([
`transcript: ${Hooks.heldNotice('forging', 'Bash', 'Bash(echo *)')}`,
])
},
)
test(
'a plugin of the person asking about another command is left out',
{ plugins: [Fixtures.rewriting] },
async ($, on) => {
on('settings.read', () => ({ value: Fixtures.MANAGED_POLICY }))
Fixtures.logged(on)
const asked: unknown[] = []
on('tool.check', ($, e) => {
asked.push(e.input)
return Fixtures.RULE_DENY
})
expect(await $.tool.check(Fixtures.CHECKED)).toEqual(Fixtures.RULE_DENY)
expect(
asked,
'the rules were asked about the command that would run, only',
).toEqual([Fixtures.CHECKED.input])
},
)
test(
'managed settings may let the plugins a person installs override',
{ plugins: [Fixtures.allowing('easy')] },
async ($, on) => {
on('settings.read', () => ({ value: Fixtures.overridePolicyOf(true) }))
const lines = Fixtures.logged(on)
const evaluations = Fixtures.checksAnswered(on, Fixtures.RULE_DENY)
expect(await $.tool.check(Fixtures.CHECKED)).toEqual(Fixtures.ALLOWED)
expect({ lines, evaluations: evaluations() }).toEqual({
lines: [],
evaluations: 1,
})
},
)
test(
"only managed settings are asked: a person's own cannot lift a deny rule",
{ plugins: [Fixtures.allowing('easy')] },
async ($, on) => {
Fixtures.policyBySource(
on,
Fixtures.MANAGED_POLICY,
Fixtures.overridePolicyOf(true),
)
Fixtures.logged(on)
Fixtures.checksAnswered(on, Fixtures.RULE_DENY)
expect(await $.tool.check(Fixtures.CHECKED)).toEqual(Fixtures.RULE_DENY)
},
)
test(
'the option mistyped lifts nothing: only the literal true does',
{ plugins: [Fixtures.allowing('easy')] },
async ($, on) => {
on('settings.read', () => ({ value: Fixtures.overridePolicyOf('true') }))
Fixtures.logged(on)
Fixtures.checksAnswered(on, Fixtures.RULE_DENY)
expect(await $.tool.check(Fixtures.CHECKED)).toEqual(Fixtures.RULE_DENY)
},
)
test(
'with a policy that cannot be read the deny rule holds: fails closed',
{ plugins: [Fixtures.allowing('easy')] },
async ($, on) => {
const stopPolicy = Fixtures.policyUntilStopped(
on,
Fixtures.overridePolicyOf(true),
'managed settings unreadable',
)
Fixtures.logged(on)
Fixtures.checksAnswered(on, Fixtures.RULE_DENY)
on('prompt.section', ($, e) => ({ text: e.text }))
await $.prompt.section(Fixtures.MEMORY)
stopPolicy()
expect(
await $.tool.check(Fixtures.CHECKED),
'the policy that read let plugins override; unread, the rule holds',
).toEqual(Fixtures.RULE_DENY)
},
)
test(
"when the rules cannot be evaluated the call is refused: the hook's catch",
{ plugins: [Fixtures.blindAllowing] },
async ($, on) => {
on('settings.read', () => ({ value: Fixtures.MANAGED_POLICY }))
Fixtures.logged(on)
on('tool.check', () => {
throw new Error('the evaluation failed')
})
expect(await $.tool.check(Fixtures.CHECKED)).toEqual(Hooks.UNCHECKED_DENY)
},
)
})