Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion mods/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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, 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 |
| [`sec-default`](sec-default) | Keeps an organization's classic hooks, prompt content, managed settings, tool policy, permission rules and pinned environment 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 |
Expand Down
2 changes: 1 addition & 1 deletion mods/sec-default/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "sec-default",
"version": "0.1.0",
"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.",
"description": "Security default for organizations: seated outermost, it keeps the organization's classic hooks, prompt content, settings, tool policy and pinned environment 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.",
"author": {
"name": "Anthropic"
}
Expand Down
83 changes: 54 additions & 29 deletions mods/sec-default/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@ 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, 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
its managed CLAUDE.md and rules, its settings and the environment they set,
its MCP allowlist, the permission rules and refusals in force on its machines)
was never within a person's reach before function hooks; seated outermost,
this plugin keeps exactly those, and the lines its own plugins log, out of the
user tier's reach and adds no policy of its own. Everything else passes
through untouched.

Expand All @@ -32,9 +33,11 @@ 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. |
| `tool.check` | A deny, and an ask a settings rule decided, hold 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 the stricter and is a deny, or names the rule behind it, it is the answer. See [Deny rules hold](#deny-rules-hold). Every other verdict passes as the chain left it. |
| `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. |
| `env.set` | A variable managed settings set in `env` (its name in any case) is pinned: setting or unsetting it, a `user`-tier caller is refused by name and any other caller continues past the user tier, so no user hook rewrites the value. A variable the organization does not set passes. With no policy to read, every variable counts as pinned. `allowModsToOverrideDenyRules` does not unpin. |
| `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`, `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.*` but `ui.log`, `fs.*`, `http.fetch`, `process.run`, `env.get`, `store.*`, `clock.*`, `model.*`, `mcp.call`, `audio.*`, `agent.list`, `engine.create`. |

## Options an administrator sets

Expand Down Expand Up @@ -87,34 +90,37 @@ not loaded. Settings hooks, status lines and `/goal` are not touched by it.

`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).
held deny rules, and over every other deny and an ask rule with it: the one
option covers all that holds there. Off unless it is the literal `true`; an
option that reads as unset leaves them holding. See
[Deny rules hold](#deny-rules-hold).

## What it hooks

`classic.*`, `prompt.section`, `prompt.context`, `prompt.compose`, `skill.prompt`,
`attribution.text`, `settings.read`, `tool.describe`, `command.describe`,
`agent.offer`, `agent.spawn`, `tool.register`, `tool.list`, `tool.check`,
`plugin.register`.
`ui.log`, `env.set`, `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.
did. So has hooking `ui.log` and `env.set`: each such call of any plugin's now
runs a chain, and an `env.set` waits on the policy read.

## What it calls on `$`

`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
person reads when something 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:
would also lift a deny rule or an ask 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
Expand All @@ -131,34 +137,53 @@ seated it does not:
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.
by the user tier alone, so a verdict here that is stricter than the chain's
answer is one the user tier loosened. A deny is returned in place of the
chain's answer whatever decided it; an ask, when it names the settings rule
that decided it (`rule`).
- Any rule counts, whoever configured it: a verdict carries the rule as
written, never where it was read from. A deny holds unnamed because it can
stand in front of a rule's ask: lifted, the call would run with nobody
asked. An ask that names no rule (the mode's own, a settings hook's, a check
of the engine's own, an organization's plugin's) 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,
twice on such a call). The mode's own ask
that a person's plugin turns into an allow, with no 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
- The person is told once for each name and kind 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)`. The kinds are `a deny rule`,
`an ask rule` and `a refusal` (a deny that names no rule); the last carries
neither `in your settings` nor a rule. 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.
run has it in the debug log alone; the call is still denied, or asked
about, with its own message. The line goes past the user tier (the `ui.log`
row): a hook of theirs that heard it would run inside this plugin's own
`tool.check` hook, and the engine leaves a hook out of whatever is raised
from inside it.
- What this does not reach: a held ask is still an ask, so whatever answers
asks for the person answers it, a classic `PermissionRequest` hook of
theirs included (`allowManagedHooksOnly` is the control for those). And a
command hook decides only if its command runs: it is spawned in the
session's environment and a hook that fails to run decides nothing, so a
variable its spawn depends on (`PATH`, `CLAUDE_CODE_SHELL_PREFIX`) that the
organization does not set in managed `env` is a person's to change, by their
own settings as by a plugin's `$.env.set`. Set in managed `env`, it is
pinned (the `env.set` row).
- 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.
one they loosened, or a run that rejected, is refused, since the 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:
rules, and all else that holds with them, says so in managed settings, under
this plugin's own options:

```json
{
Expand All @@ -173,7 +198,7 @@ rules says so in managed settings, under this plugin's own options:
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.
policy that cannot be read leaves them all holding.

## Where it is seated

Expand Down
2 changes: 1 addition & 1 deletion mods/sec-default/hooks/held-verdict/caught-answer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import Verdicts from './verdicts'
* 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.
* plugin loosened. A loosened one, or none at all, met no rule.
*
* @param last what that run settled on; undefined when it rejected
* @param trace that run's `next.trace`
Expand Down
11 changes: 11 additions & 0 deletions mods/sec-default/hooks/held-verdict/held-kind.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import type { EventResult } from 'claude-code'

/**
* What kind of thing held over a person's plugin, as the notice calls it:
* `deny rule`, `ask rule`, or `refusal`, a deny that names no rule.
*
* @param held the verdict that holds
* @returns the kind
*/
export const heldKind = (held: EventResult<'tool.check'>) =>
held.rule === undefined ? 'refusal' : `${held.decision} rule`
29 changes: 22 additions & 7 deletions mods/sec-default/hooks/held-verdict/held-notice.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,30 @@
import type { EventResult } from 'claude-code'

import { heldKind } from './held-kind.js'

/**
* 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.
* What a person reads, once for each plugin and kind in a session, when a
* plugin they installed answered more permissively than what holds over it.
*
* 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
* @param held the verdict that holds, naming the rule behind it if one is
* @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)'
export function heldNotice(
plugin: string,
tool: string,
held: EventResult<'tool.check'>,
) {
const kind = heldKind(held)
const isRuled = held.rule !== undefined

return (
`${plugin} tried to lift ${kind.startsWith('a') ? 'an' : 'a'} ${kind}` +
`${isRuled ? ' in your settings' : ''} from a ${tool} call` +
`${isRuled ? ` (${held.rule})` : ''}; the ${kind} holds over the ` +
'plugins you install (allowModsToOverrideDenyRules)'
)
}
1 change: 1 addition & 0 deletions mods/sec-default/hooks/held-verdict/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export * from './caught-answer.js'
export * from './held-kind.js'
export * from './held-notice.js'
export * from './verdicts'

Expand Down
22 changes: 22 additions & 0 deletions mods/sec-default/hooks/held-verdict/verdicts/holds-over.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import type { EventResult } from 'claude-code'

import Ranking from './ranking'

/**
* Whether the verdict reached past the user tier holds over the chain's
* answer: it is the stricter, and a deny, or an ask a settings rule decided.
*
* Any rule counts, whoever configured it: a verdict names the rule, never its
* source. A deny holds unnamed: it may stand before a rule's ask. The mode's
* own ask does not hold, nor a settings hook's.
*
* @param held what the run past the user tier settled on
* @param answer what the whole chain settled on
* @returns true when the plugins a person installs may not have loosened it
*/
export const holdsOver = (
held: EventResult<'tool.check'>,
answer: EventResult<'tool.check'>,
) =>
Ranking.isLooser(answer, held) &&
(held.decision === 'deny' || held.rule !== undefined)
3 changes: 1 addition & 2 deletions mods/sec-default/hooks/held-verdict/verdicts/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
export * from './is-rule-deny.js'
export * from './holds-over.js'
export * from './loosened-by-users.js'
export * from './ranking'
export * from './types'
export * from './unchecked-deny.js'

export * as default from '.'
18 changes: 0 additions & 18 deletions mods/sec-default/hooks/held-verdict/verdicts/is-rule-deny.ts

This file was deleted.

3 changes: 0 additions & 3 deletions mods/sec-default/hooks/held-verdict/verdicts/types/index.ts

This file was deleted.

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@ 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.
* reached, and no check of the rules vouches for it: a 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, ' +
'the rules in your settings could not be checked for this call, ' +
'so it is refused',
})
2 changes: 1 addition & 1 deletion mods/sec-default/hooks/hooks.json
Original file line number Diff line number Diff line change
@@ -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, 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",
"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 deny, and an ask a settings rule decided, over a user-tier allow or ask on tool.check, keeps the organization's own log lines and the variables its managed env sets out of the user tier's reach, and refuses a user-tier hooks module at plugin.register while managed settings set its allowManagedModsOnly option",
"modules": ["./register.ts"]
}
1 change: 1 addition & 0 deletions mods/sec-default/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export * from './admission-failure'
export * from './held-verdict'
export * from './managed-mods-only-refusal'
export * from './past-users'
export * from './pinned-variable-refusal'
export * from './policy'
export * from './register.js'
export * from './tool-register-refusal'
Expand Down
Loading
Loading