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
111 changes: 96 additions & 15 deletions mods/sec-default/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@ 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
user tier's reach and adds no policy of its own. Everything else passes
through untouched.
rules in force on its machines, the approval it requires for a connector's
tool) 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
Expand All @@ -17,6 +17,12 @@ pinned `e.tier` is), or pass (`next(e)`). A subject's provenance is the
event's pinned `e.provider`; policy is read through
`$.settings.read({ source: "policy" })`, one read serving a burst of tool
calls; both fail closed, so an unreadable policy counts as a policy in force.
Every hook carries a `.catch` but the five that only pass prompt content
and attribution text over the user tier (`prompt.section`,
`prompt.context`, `prompt.compose`, `skill.prompt`, `attribution.text`),
and no `.catch` depends on a call on `$` succeeding: one that reads policy
reads it itself, a read that rejects or throws counts as a policy in force,
and a line that cannot be logged changes no answer.

`hooks/register.ts` is the module; `hooks/policy/` reads the managed
settings it decides by.
Expand All @@ -25,16 +31,17 @@ settings it decides by.

| event | from the outermost seat |
| --- | --- |
| `classic.*` | Continue past the user tier: the organization's settings hooks see the engine's input and their answer stands. |
| `classic.*` | Continue past the user tier: the organization's settings hooks see the engine's input and their answer stands. If the hook fails before continuing, its `.catch` continues past the user tier all the same. |
| `prompt.section`, `prompt.context`, `skill.prompt`, `attribution.text` | Continue past the user tier: managed CLAUDE.md, rules and policy skills reach the model as written. A person's plugins keep `prompt.submit` and its additive context. |
| `prompt.compose` | Continue past the user tier: the system prompt's list of sections is what the organization's tiers, the built-ins and the engine's own composition make it. A person's plugin neither drops, reorders nor rewrites a section, nor changes the facts the list is composed from, nor answers a list of its own in its place. The engine raises this event only when some loaded plugin hooks it, so where this plugin is seated every render of the system prompt runs the chain. |
| `settings.read` | Continue past the user tier: no user hook rewrites what any caller reads as settings, this plugin's own policy reads included. |
| `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. |
| `settings.read` | Continue past the user tier: no user hook rewrites what any caller reads as settings, this plugin's own policy reads included. If the hook fails before continuing, its `.catch` continues past the user tier all the same. |
| `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. If the hook fails before it decided, its `.catch` decides the same way. |
| `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. If the hook fails before it decided, its `.catch` decides the same way on a policy read of its own, and a policy it cannot read counts as an allowlist in force. |
| `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. If the hook fails, its `.catch` answers the organization's listing 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). So does the ceiling an organization set on a tool (`e.ceiling`): when the chain's answer is more permissive than the ceiling, the dispatch is run again past the user tier, and if that verdict is stricter, it is the answer. See [An organization's ceiling holds](#an-organizations-ceiling-holds). Every other verdict passes as the chain left it. |
| `ui.log` | A caller whose tier is `user`, `builtin` or `core` passes. Any other caller's line (an organization's plugin in `prepend` or `append`, this plugin's own lines included) continues past the user tier: no plugin a person installed hears, rewrites or drops it. See [Its lines pass over the user tier](#its-lines-pass-over-the-user-tier). |
| `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`, `store.*`, `clock.*`, `model.*`, `mcp.call`, `audio.*`, `agent.list`, `engine.create`. |

## Options an administrator sets

Expand Down Expand Up @@ -95,17 +102,18 @@ as unset leaves deny rules holding. See [Deny rules hold](#deny-rules-hold).
`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`, `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. The same goes for `ui.log`: every line a plugin logs runs that chain.

## 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 a deny rule or an organization's ceiling held over a
plugin of theirs. Both pass over the user tier. It continues to
the `append` tier with `next.to`, which only a plugin in a managed tier may
do.

Expand Down Expand Up @@ -141,7 +149,9 @@ seated it does not:
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.
stands: that is what such a plugin is for. The one ask that does not is
the ask an organization requires for a tool: see
[An organization's ceiling holds](#an-organizations-ceiling-holds).
- `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.
Expand Down Expand Up @@ -175,6 +185,77 @@ 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.

## An organization's ceiling holds

An organization's administrators can require approval for a connector's
tool. The engine then asks for every call of it, whatever the permission
mode, an allow rule, the auto-mode classifier or a settings hook says, and
names that on `tool.check` as the question's `ceiling` (`"ask"`): the most
permissive verdict the organization lets a call of the tool reach. Where
this plugin is seated, the ceiling holds over the plugins a person
installs:

- The hook first runs the chain as it is. If the answer is no more
permissive than the ceiling (an ask or a deny under an `ask` ceiling), or
the tool has no ceiling, this hold does nothing and adds no run of its
own.
- Otherwise it runs the dispatch once more with the user tier left out
(`next.to(e, "append")`), exactly as for a deny rule. If that verdict is
stricter than the chain's answer, it is returned in its place: the ask
the organization requires, or whatever stricter verdict its own tiers and
the engine settled on (a deny rule's deny included, whatever
`allowModsToOverrideDenyRules` says: an allow is not within the ceiling,
so the person's plugins have no say in that call). It never returns a
verdict more permissive than the chain's.
- Whether the chain's answer is over the ceiling is read off the answer and
the question alone, never off `next.trace`: however a person's plugin
arrived at an allow, with or without calling `next`, the answer is put
to the run that leaves the user tier out. The trace only names who is
told.
- `tool.check` pins `ceiling` with the rest of the question and the engine
sets it from the tool, so a hook can neither ask beneath under another
ceiling (its hook fails) nor write one into its answer (it is dropped).
A ceiling this plugin does not know (anything but `allow`, `ask` or
`deny`) is ranked as a deny: every answer but a deny is put to the run
that leaves the user tier out, and the failure handler answers a deny.
- An organization's plugin (prepend or append) or a built-in that allows
over the ceiling takes part in both runs, so its answer stands, at the
cost of the second run on such a call. An ask or a deny from a person's
plugin is within the ceiling and stands as any other.
- No option lifts it. `allowModsToOverrideDenyRules` is about deny rules in
settings files and is not read for this; the ceiling is changed where the
organization's administrators set it.
- The person is told once for each name in a session, in the transcript
and the debug log: `<plugin> tried to lift the limit your organization
set on <tool> (ask); the limit holds over the plugins you install`. Only
a link that itself answered over the ceiling is named, as the engine
names it (a batch by its members joined). A plain `-p` run has it in the
debug log alone; the call is still put to the person, or to the host
that answers for them.
- If the hook itself fails, its `.catch` lets nothing over the ceiling
through: whatever it would answer for the deny rules (above), a verdict
more permissive than the ceiling becomes the ceiling's own
(`{ decision: "ask" }`, saying the limit could not be checked). On that
path an organization plugin's allow over the ceiling becomes the ask
too. A run that rejected leaves no verdict to hold at the ceiling, and
none is made up: what the handler answers for the deny rules stands, as
it does for any other tool.

## Its lines pass over the user tier

What an organization's plugin tells a person is the organization's to
word, like its prompt content and its settings. So `ui.log` from any caller
but one in `user`, `builtin` or `core` continues past the user tier
(`next.to(e, "append")`): the line reaches the transcript and the debug log
through the organization's own tiers and the built-ins alone, and no plugin
a person installed hears, rewrites or drops it. That covers this plugin's
own lines (the ones a person reads when a deny rule or a ceiling held) and
those of any other plugin the organization deploys.

What a person's plugins, the built-ins and the engine log is heard by the
user tier as before. If the hook fails before continuing, its `.catch`
continues past the user tier whoever the caller is.

## Where it is seated

The CLI seats it first in the prepend tier wherever hooks modules load on a
Expand Down
4 changes: 2 additions & 2 deletions mods/sec-default/hooks/admission-failure/admission-failure.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { HookFailure } from 'claude-code'
import type { FailureRead } from './failure-read'

/**
* The debug line for a `plugin.register` hook of this plugin that failed:
Expand All @@ -8,6 +8,6 @@ import type { HookFailure } from 'claude-code'
* @param error why the hook failed, as its `.catch` reads it
* @returns the line
*/
export const admissionFailure = (name: string, error: HookFailure) =>
export const admissionFailure = (name: string, error: FailureRead) =>
`plugin.register hook failed judging ${name} (${error.kind}): ` +
(error.message ?? 'no message')
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/**
* What the debug line reads of a hook's failure: its kind and what it said.
*/
export type FailureRead = {
readonly kind: string
readonly message?: string
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export type * from './failure-read.js'

export * as default from '.'
1 change: 1 addition & 0 deletions mods/sec-default/hooks/admission-failure/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './admission-failure.js'
export * from './failure-read'

export * as default from '.'
9 changes: 9 additions & 0 deletions mods/sec-default/hooks/attempted/attempted.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
/**
* Makes a call on `$` at once and hands back a promise of what it settles
* to; a call that throws where it is made rejects that promise.
*
* @param call the call, made inside the hook that asked for it
* @returns what the call settles to
*/
export const attempted = <T>(call: () => T | Promise<T>) =>
(async () => call())()
3 changes: 3 additions & 0 deletions mods/sec-default/hooks/attempted/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export * from './attempted.js'

export * as default from '.'
26 changes: 26 additions & 0 deletions mods/sec-default/hooks/caught/admission-caught.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { admissionFailure } from '../admission-failure'
import { managedModsOnlyRefusal } from '../managed-mods-only-refusal'
import { quietly } from '../quietly'
import type Types from './types'

/**
* The `plugin.register` hook's failure handler: the failed hook's last run
* when it made one, else the module is refused.
*
* It names the failure in the debug log; that call's outcome changes
* nothing of the answer.
*
* @param e the module being judged
* @param next the handler's continuation, with why the hook failed
* @param log the handler's own line to the debug log
* @returns the admission's answer
*/
export function admissionCaught<E extends Types.Judged, R>(
e: E,
next: Types.CaughtNext<E, R> & Types.Failed,
log: (line: string) => unknown,
) {
quietly(() => log(admissionFailure(e.name, next.error)))

return next.called ? next(e) : { refuse: managedModsOnlyRefusal(e.name) }
}
37 changes: 37 additions & 0 deletions mods/sec-default/hooks/caught/check-caught.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import type { Args, Settings } from 'claude-code'

import { attempted } from '../attempted'
import HeldVerdict from '../held-verdict'
import Policy from '../policy'
import type Types from './types'

/**
* The `tool.check` hook's failure handler: the failed hook's last run when
* it vouches for itself, else a refusal; never over the tool's ceiling.
*
* Its own policy read fails closed and cannot throw: a read that rejects,
* or throws where it is made, counts as deny rules holding.
*
* @param e the question
* @param next the handler's continuation, with that run's trace
* @param read the handler's own read of managed policy
* @returns the verdict; nothing only when that run rejected and policy
* lets plugins override deny rules
*/
export async function checkCaught(
e: Args<'tool.check'>,
next: Types.CheckNext,
read: () => Promise<Settings>,
) {
const last = await next(e).catch(() => undefined)

const shouldVouch = await Policy.decidedByPolicy(
attempted(read),
Policy.denyRulesHold,
)

return HeldVerdict.underCeiling(
shouldVouch ? HeldVerdict.caughtAnswer(last, next.trace) : last,
e.ceiling,
)
}
8 changes: 8 additions & 0 deletions mods/sec-default/hooks/caught/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export * from './admission-caught.js'
export * from './check-caught.js'
export * from './past-users-caught.js'
export * from './provided-caught.js'
export * from './register-caught.js'
export * from './types'

export * as default from '.'
15 changes: 15 additions & 0 deletions mods/sec-default/hooks/caught/past-users-caught.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import type Types from './types'

/**
* The failure handler of a hook whose refusal is the run past the user tier
* (`classic.*`, `settings.read`, `tool.list`, `ui.log`), with no call on `$`.
*
* Where the failed hook had already continued, that call's answer comes
* back in its place.
*
* @param e the event's input
* @param next the handler's continuation
* @returns the run past the user tier, or the failed hook's last call
*/
export const pastUsersCaught = <E, R>(e: E, next: Types.CaughtNext<E, R>) =>
next.called ? next(e) : next.to(e, 'append')
19 changes: 19 additions & 0 deletions mods/sec-default/hooks/caught/provided-caught.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { pastUsers } from '../past-users'
import type PastUsers from '../past-users'
import type Types from './types'

/**
* The failure handler of a hook that lets an organization's subject
* continue past the user tier: the hook's own decision, made again.
*
* Where the failed hook had already continued, that call's answer comes
* back in its place. It reads the subject's pinned provider alone.
*
* @param e the event's input with its pinned `provider`
* @param next the handler's continuation
* @returns the hook's answer
*/
export const providedCaught = <E extends PastUsers.Provided, R>(
e: E,
next: Types.CaughtNext<E, R>,
) => (next.called ? next(e) : pastUsers(e, next))
30 changes: 30 additions & 0 deletions mods/sec-default/hooks/caught/register-caught.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import type { Settings } from 'claude-code'

import { attempted } from '../attempted'
import Policy from '../policy'
import { toolRegistered } from '../tool-registered'
import type ToolRegistered from '../tool-registered'
import type Types from './types'

/**
* The `tool.register` hook's failure handler: the failed hook's last run
* when it made one, else the hook's own decision made again.
*
* Its policy read fails closed and cannot throw: a read that rejects, or
* throws where it is made, refuses a caller in the user tier.
*
* @param e the tool being added
* @param next the handler's continuation, with the caller's origin
* @param read the handler's own read of managed policy
* @returns the registration's answer
*/
export const registerCaught = <E, R>(
e: E,
next: Types.CaughtNext<E, R> & ToolRegistered.RegisterNext<E, R>,
read: () => Promise<Settings>,
) =>
next.called
? next(e)
: toolRegistered(e, next, () =>
Policy.decidedByPolicy(attempted(read), Policy.hasMcpAllowlist),
)
Loading
Loading