spacehub

surshar / claude-code

Claude Code is an agentic coding tool that lives in your terminal, understands your codebase, and helps you code faster by executing routine tasks, explaining complex code, and handling git workflows - all through natural language commands.

Built by Anthropic · github.com/anthropics

sec-default: an organization's ceiling on a tool holds over the plugins a person installsBrowse files
An organization can require approval for a connector's tool; the engine
names that on tool.check as the question's ceiling. Seated, the policy mod
holds it over the user tier as it holds a deny rule: an answer more
permissive than the ceiling is put to the run that leaves the user tier
out, and a stricter verdict there is the answer.

What an organization's plugin logs continues past the user tier, this
mod's own lines included.

Every hook that decides carries a .catch that decides as its hook does and
depends on no call on $ succeeding.
Alice Poteat authored 19 days ago parent 2bfb629 · commit 318196e284b0
77 changed files+1832 -77compared with 2bfb629
Mmods/sec-default/README.md
Mmods/sec-default/hooks/admission-failure/admission-failure.ts
Amods/sec-default/hooks/admission-failure/failure-read/failure-read.ts
Amods/sec-default/hooks/admission-failure/failure-read/index.ts
Mmods/sec-default/hooks/admission-failure/index.ts
Amods/sec-default/hooks/attempted/attempted.ts
Amods/sec-default/hooks/attempted/index.ts
Amods/sec-default/hooks/caught/admission-caught.ts
Amods/sec-default/hooks/caught/check-caught.ts
Amods/sec-default/hooks/caught/index.ts
Amods/sec-default/hooks/caught/past-users-caught.ts
Amods/sec-default/hooks/caught/provided-caught.ts
Amods/sec-default/hooks/caught/register-caught.ts
Amods/sec-default/hooks/caught/types/caught-next.ts
Amods/sec-default/hooks/caught/types/check-next.ts
Amods/sec-default/hooks/caught/types/failed.ts
Amods/sec-default/hooks/caught/types/index.ts
Amods/sec-default/hooks/caught/types/judged.ts
Amods/sec-default/hooks/held-verdict/ceiling-notice.ts
Mmods/sec-default/hooks/held-verdict/index.ts
Amods/sec-default/hooks/held-verdict/under-ceiling.ts
Amods/sec-default/hooks/held-verdict/untold.ts
Mmods/sec-default/hooks/held-verdict/verdicts/index.ts
Amods/sec-default/hooks/held-verdict/verdicts/is-ceiling-held.ts
Amods/sec-default/hooks/held-verdict/verdicts/lifted-by-users.ts
Mmods/sec-default/hooks/held-verdict/verdicts/loosened-by-users.ts
Amods/sec-default/hooks/held-verdict/verdicts/loosened-links/index.ts
Amods/sec-default/hooks/held-verdict/verdicts/loosened-links/loosened-links.ts
Amods/sec-default/hooks/held-verdict/verdicts/ranking/ceiling-verdict/ceiling-verdict.ts
Amods/sec-default/hooks/held-verdict/verdicts/ranking/ceiling-verdict/index.ts
Mmods/sec-default/hooks/held-verdict/verdicts/ranking/index.ts
Amods/sec-default/hooks/held-verdict/verdicts/ranking/is-over-ceiling.ts
Amods/sec-default/hooks/held-verdict/verdicts/unchecked-ceiling.ts
Mmods/sec-default/hooks/hooks.json
Mmods/sec-default/hooks/index.ts
Mmods/sec-default/hooks/past-users/user-reachable-tiers/user-reachable-tiers.ts
Mmods/sec-default/hooks/policy/create-policy-memo/create-policy-memo.ts
Amods/sec-default/hooks/quietly/index.ts
Amods/sec-default/hooks/quietly/quietly.ts
Mmods/sec-default/hooks/register.ts
Amods/sec-default/hooks/tool-registered/index.ts
Amods/sec-default/hooks/tool-registered/tool-registered.ts
Amods/sec-default/hooks/tool-registered/types/index.ts
Amods/sec-default/hooks/tool-registered/types/register-next.ts
Amods/sec-default/hooks/tools-listed/index.ts
Amods/sec-default/hooks/tools-listed/tools-listed.ts
Amods/sec-default/tests/caught.test.ts
Amods/sec-default/tests/fixtures/caught/answering.ts
Amods/sec-default/tests/fixtures/caught/handlers-registered.ts
Amods/sec-default/tests/fixtures/caught/index.ts
Amods/sec-default/tests/fixtures/caught/judged.ts
Amods/sec-default/tests/fixtures/caught/orgs-subject.ts
Amods/sec-default/tests/fixtures/caught/pass-over-events.ts
Amods/sec-default/tests/fixtures/caught/rechecked.ts
Amods/sec-default/tests/fixtures/caught/replaying.ts
Amods/sec-default/tests/fixtures/caught/started.ts
Amods/sec-default/tests/fixtures/caught/subject-events.ts
Amods/sec-default/tests/fixtures/caught/theirs-subject.ts
Amods/sec-default/tests/fixtures/caught/throwing.ts
Amods/sec-default/tests/fixtures/caught/uncalled.ts
Amods/sec-default/tests/fixtures/caught/unchecked.ts
Amods/sec-default/tests/fixtures/caught/unreadable.ts
Mmods/sec-default/tests/fixtures/index.ts
Amods/sec-default/tests/fixtures/logs/index.ts
Amods/sec-default/tests/fixtures/logs/log-swallowing.ts
Amods/sec-default/tests/fixtures/logs/logging.ts
Amods/sec-default/tests/fixtures/tool-check/capped-allowed.ts
Amods/sec-default/tests/fixtures/tool-check/capped-ask.ts
Amods/sec-default/tests/fixtures/tool-check/capped-rule-deny.ts
Amods/sec-default/tests/fixtures/tool-check/capped.ts
Amods/sec-default/tests/fixtures/tool-check/ceiling-forging.ts
Amods/sec-default/tests/fixtures/tool-check/ceiling-lifting.ts
Mmods/sec-default/tests/fixtures/tool-check/index.ts
Mmods/sec-default/tests/held-verdict.test.ts
Mmods/sec-default/tests/policy/create-policy-memo.test.ts
Mmods/sec-default/tests/register.test.ts
Mmods/types/claude-code.d.ts
@@ -5,10 +5,10 @@
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,
77 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.
8+rules in force on its machines, the approval it requires for a connector's
9+tool) was never within a person's reach before function hooks; seated
10+outermost, this plugin keeps exactly those out of the user tier's reach and
11+adds no policy of its own. Everything else passes through untouched.
1212
1313 It has three moves and nothing else: continue past the user tier
1414 (`next.to(e, "append")`), refuse a user-tier caller or module by name
@@ -17,6 +17,12 @@
1717 event's pinned `e.provider`; policy is read through
1818 `$.settings.read({ source: "policy" })`, one read serving a burst of tool
1919 calls; both fail closed, so an unreadable policy counts as a policy in force.
20+Every hook carries a `.catch` but the five that only pass prompt content
21+and attribution text over the user tier (`prompt.section`,
22+`prompt.context`, `prompt.compose`, `skill.prompt`, `attribution.text`),
23+and no `.catch` depends on a call on `$` succeeding: one that reads policy
24+reads it itself, a read that rejects or throws counts as a policy in force,
25+and a line that cannot be logged changes no answer.
2026
2127 `hooks/register.ts` is the module; `hooks/policy/` reads the managed
2228 settings it decides by.
@@ -25,16 +31,17 @@
2531
2632 | event | from the outermost seat |
2733 | --- | --- |
28-| `classic.*` | Continue past the user tier: the organization's settings hooks see the engine's input and their answer stands. |
34+| `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. |
2935 | `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. |
3036 | `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. |
31-| `settings.read` | Continue past the user tier: no user hook rewrites what any caller reads as settings, this plugin's own policy reads included. |
32-| `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. |
33-| `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. |
34-| `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. |
35-| `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. |
37+| `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. |
38+| `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. |
39+| `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. |
40+| `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. |
41+| `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. |
42+| `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). |
3643 | `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. |
37-| 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`. |
44+| 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`. |
3845
3946 ## Options an administrator sets
4047
@@ -95,17 +102,18 @@
95102 `classic.*`, `prompt.section`, `prompt.context`, `prompt.compose`, `skill.prompt`,
96103 `attribution.text`, `settings.read`, `tool.describe`, `command.describe`,
97104 `agent.offer`, `agent.spawn`, `tool.register`, `tool.list`, `tool.check`,
98-`plugin.register`.
105+`ui.log`, `plugin.register`.
99106
100107 Hooking `tool.check` has a cost: the engine raises that event only when some
101108 loaded plugin hooks it, so where this plugin is seated every tool call now
102109 runs the `tool.check` chain, where before only a session with such a plugin
103-did.
110+did. The same goes for `ui.log`: every line a plugin logs runs that chain.
104111
105112 ## What it calls on `$`
106113
107114 `settings.read`, and `ui.log`: to the debug log, and for the one line a
108-person reads when a deny rule held over a plugin of theirs. It continues to
115+person reads when a deny rule or an organization's ceiling held over a
116+plugin of theirs. Both pass over the user tier. It continues to
109117 the `append` tier with `next.to`, which only a plugin in a managed tier may
110118 do.
111119
@@ -141,7 +149,9 @@
141149 one that loosens is what brings the second run about, so its hooks run
142150 twice on such a call). An ask
143151 that a person's plugin turns into an allow, with no deny rule behind it,
144- stands: that is what such a plugin is for.
152+ stands: that is what such a plugin is for. The one ask that does not is
153+ the ask an organization requires for a tool: see
154+ [An organization's ceiling holds](#an-organizations-ceiling-holds).
145155 - `tool.check` pins the question (`tool`, `input`, `tool_use_id`), so no hook
146156 can have the rules evaluated on one command and another run; a rewrite
147157 belongs to `tool.call`, which runs before any of this.
@@ -175,6 +185,77 @@
175185 `--settings`, is never consulted; only the literal `true` counts, and a
176186 policy that cannot be read leaves deny rules holding.
177187
188+## An organization's ceiling holds
189+
190+An organization's administrators can require approval for a connector's
191+tool. The engine then asks for every call of it, whatever the permission
192+mode, an allow rule, the auto-mode classifier or a settings hook says, and
193+names that on `tool.check` as the question's `ceiling` (`"ask"`): the most
194+permissive verdict the organization lets a call of the tool reach. Where
195+this plugin is seated, the ceiling holds over the plugins a person
196+installs:
197+
198+- The hook first runs the chain as it is. If the answer is no more
199+ permissive than the ceiling (an ask or a deny under an `ask` ceiling), or
200+ the tool has no ceiling, this hold does nothing and adds no run of its
201+ own.
202+- Otherwise it runs the dispatch once more with the user tier left out
203+ (`next.to(e, "append")`), exactly as for a deny rule. If that verdict is
204+ stricter than the chain's answer, it is returned in its place: the ask
205+ the organization requires, or whatever stricter verdict its own tiers and
206+ the engine settled on (a deny rule's deny included, whatever
207+ `allowModsToOverrideDenyRules` says: an allow is not within the ceiling,
208+ so the person's plugins have no say in that call). It never returns a
209+ verdict more permissive than the chain's.
210+- Whether the chain's answer is over the ceiling is read off the answer and
211+ the question alone, never off `next.trace`: however a person's plugin
212+ arrived at an allow, with or without calling `next`, the answer is put
213+ to the run that leaves the user tier out. The trace only names who is
214+ told.
215+- `tool.check` pins `ceiling` with the rest of the question and the engine
216+ sets it from the tool, so a hook can neither ask beneath under another
217+ ceiling (its hook fails) nor write one into its answer (it is dropped).
218+ A ceiling this plugin does not know (anything but `allow`, `ask` or
219+ `deny`) is ranked as a deny: every answer but a deny is put to the run
220+ that leaves the user tier out, and the failure handler answers a deny.
221+- An organization's plugin (prepend or append) or a built-in that allows
222+ over the ceiling takes part in both runs, so its answer stands, at the
223+ cost of the second run on such a call. An ask or a deny from a person's
224+ plugin is within the ceiling and stands as any other.
225+- No option lifts it. `allowModsToOverrideDenyRules` is about deny rules in
226+ settings files and is not read for this; the ceiling is changed where the
227+ organization's administrators set it.
228+- The person is told once for each name in a session, in the transcript
229+ and the debug log: `<plugin> tried to lift the limit your organization
230+ set on <tool> (ask); the limit holds over the plugins you install`. Only
231+ a link that itself answered over the ceiling is named, as the engine
232+ names it (a batch by its members joined). A plain `-p` run has it in the
233+ debug log alone; the call is still put to the person, or to the host
234+ that answers for them.
235+- If the hook itself fails, its `.catch` lets nothing over the ceiling
236+ through: whatever it would answer for the deny rules (above), a verdict
237+ more permissive than the ceiling becomes the ceiling's own
238+ (`{ decision: "ask" }`, saying the limit could not be checked). On that
239+ path an organization plugin's allow over the ceiling becomes the ask
240+ too. A run that rejected leaves no verdict to hold at the ceiling, and
241+ none is made up: what the handler answers for the deny rules stands, as
242+ it does for any other tool.
243+
244+## Its lines pass over the user tier
245+
246+What an organization's plugin tells a person is the organization's to
247+word, like its prompt content and its settings. So `ui.log` from any caller
248+but one in `user`, `builtin` or `core` continues past the user tier
249+(`next.to(e, "append")`): the line reaches the transcript and the debug log
250+through the organization's own tiers and the built-ins alone, and no plugin
251+a person installed hears, rewrites or drops it. That covers this plugin's
252+own lines (the ones a person reads when a deny rule or a ceiling held) and
253+those of any other plugin the organization deploys.
254+
255+What a person's plugins, the built-ins and the engine log is heard by the
256+user tier as before. If the hook fails before continuing, its `.catch`
257+continues past the user tier whoever the caller is.
258+
178259 ## Where it is seated
179260
180261 The CLI seats it first in the prepend tier wherever hooks modules load on a
@@ -1,4 +1,4 @@
1-import type { HookFailure } from 'claude-code'
1+import type { FailureRead } from './failure-read'
22
33 /**
44 * The debug line for a `plugin.register` hook of this plugin that failed:
@@ -8,6 +8,6 @@
88 * @param error why the hook failed, as its `.catch` reads it
99 * @returns the line
1010 */
11-export const admissionFailure = (name: string, error: HookFailure) =>
11+export const admissionFailure = (name: string, error: FailureRead) =>
1212 `plugin.register hook failed judging ${name} (${error.kind}): ` +
1313 (error.message ?? 'no message')
@@ -1,0 +1,7 @@
1+/**
2+ * What the debug line reads of a hook's failure: its kind and what it said.
3+ */
4+export type FailureRead = {
5+ readonly kind: string
6+ readonly message?: string
7+}
@@ -1,0 +1,3 @@
1+export type * from './failure-read.js'
2+
3+export * as default from '.'
@@ -1,3 +1,4 @@
11 export * from './admission-failure.js'
2+export * from './failure-read'
23
34 export * as default from '.'
@@ -1,0 +1,9 @@
1+/**
2+ * Makes a call on `$` at once and hands back a promise of what it settles
3+ * to; a call that throws where it is made rejects that promise.
4+ *
5+ * @param call the call, made inside the hook that asked for it
6+ * @returns what the call settles to
7+ */
8+export const attempted = <T>(call: () => T | Promise<T>) =>
9+ (async () => call())()
@@ -1,0 +1,3 @@
1+export * from './attempted.js'
2+
3+export * as default from '.'
@@ -1,0 +1,26 @@
1+import { admissionFailure } from '../admission-failure'
2+import { managedModsOnlyRefusal } from '../managed-mods-only-refusal'
3+import { quietly } from '../quietly'
4+import type Types from './types'
5+
6+/**
7+ * The `plugin.register` hook's failure handler: the failed hook's last run
8+ * when it made one, else the module is refused.
9+ *
10+ * It names the failure in the debug log; that call's outcome changes
11+ * nothing of the answer.
12+ *
13+ * @param e the module being judged
14+ * @param next the handler's continuation, with why the hook failed
15+ * @param log the handler's own line to the debug log
16+ * @returns the admission's answer
17+ */
18+export function admissionCaught<E extends Types.Judged, R>(
19+ e: E,
20+ next: Types.CaughtNext<E, R> & Types.Failed,
21+ log: (line: string) => unknown,
22+) {
23+ quietly(() => log(admissionFailure(e.name, next.error)))
24+
25+ return next.called ? next(e) : { refuse: managedModsOnlyRefusal(e.name) }
26+}
@@ -1,0 +1,37 @@
1+import type { Args, Settings } from 'claude-code'
2+
3+import { attempted } from '../attempted'
4+import HeldVerdict from '../held-verdict'
5+import Policy from '../policy'
6+import type Types from './types'
7+
8+/**
9+ * The `tool.check` hook's failure handler: the failed hook's last run when
10+ * it vouches for itself, else a refusal; never over the tool's ceiling.
11+ *
12+ * Its own policy read fails closed and cannot throw: a read that rejects,
13+ * or throws where it is made, counts as deny rules holding.
14+ *
15+ * @param e the question
16+ * @param next the handler's continuation, with that run's trace
17+ * @param read the handler's own read of managed policy
18+ * @returns the verdict; nothing only when that run rejected and policy
19+ * lets plugins override deny rules
20+ */
21+export async function checkCaught(
22+ e: Args<'tool.check'>,
23+ next: Types.CheckNext,
24+ read: () => Promise<Settings>,
25+) {
26+ const last = await next(e).catch(() => undefined)
27+
28+ const shouldVouch = await Policy.decidedByPolicy(
29+ attempted(read),
30+ Policy.denyRulesHold,
31+ )
32+
33+ return HeldVerdict.underCeiling(
34+ shouldVouch ? HeldVerdict.caughtAnswer(last, next.trace) : last,
35+ e.ceiling,
36+ )
37+}
@@ -1,0 +1,8 @@
1+export * from './admission-caught.js'
2+export * from './check-caught.js'
3+export * from './past-users-caught.js'
4+export * from './provided-caught.js'
5+export * from './register-caught.js'
6+export * from './types'
7+
8+export * as default from '.'
@@ -1,0 +1,15 @@
1+import type Types from './types'
2+
3+/**
4+ * The failure handler of a hook whose refusal is the run past the user tier
5+ * (`classic.*`, `settings.read`, `tool.list`, `ui.log`), with no call on `$`.
6+ *
7+ * Where the failed hook had already continued, that call's answer comes
8+ * back in its place.
9+ *
10+ * @param e the event's input
11+ * @param next the handler's continuation
12+ * @returns the run past the user tier, or the failed hook's last call
13+ */
14+export const pastUsersCaught = <E, R>(e: E, next: Types.CaughtNext<E, R>) =>
15+ next.called ? next(e) : next.to(e, 'append')
@@ -1,0 +1,19 @@
1+import { pastUsers } from '../past-users'
2+import type PastUsers from '../past-users'
3+import type Types from './types'
4+
5+/**
6+ * The failure handler of a hook that lets an organization's subject
7+ * continue past the user tier: the hook's own decision, made again.
8+ *
9+ * Where the failed hook had already continued, that call's answer comes
10+ * back in its place. It reads the subject's pinned provider alone.
11+ *
12+ * @param e the event's input with its pinned `provider`
13+ * @param next the handler's continuation
14+ * @returns the hook's answer
15+ */
16+export const providedCaught = <E extends PastUsers.Provided, R>(
17+ e: E,
18+ next: Types.CaughtNext<E, R>,
19+) => (next.called ? next(e) : pastUsers(e, next))
@@ -1,0 +1,30 @@
1+import type { Settings } from 'claude-code'
2+
3+import { attempted } from '../attempted'
4+import Policy from '../policy'
5+import { toolRegistered } from '../tool-registered'
6+import type ToolRegistered from '../tool-registered'
7+import type Types from './types'
8+
9+/**
10+ * The `tool.register` hook's failure handler: the failed hook's last run
11+ * when it made one, else the hook's own decision made again.
12+ *
13+ * Its policy read fails closed and cannot throw: a read that rejects, or
14+ * throws where it is made, refuses a caller in the user tier.
15+ *
16+ * @param e the tool being added
17+ * @param next the handler's continuation, with the caller's origin
18+ * @param read the handler's own read of managed policy
19+ * @returns the registration's answer
20+ */
21+export const registerCaught = <E, R>(
22+ e: E,
23+ next: Types.CaughtNext<E, R> & ToolRegistered.RegisterNext<E, R>,
24+ read: () => Promise<Settings>,
25+) =>
26+ next.called
27+ ? next(e)
28+ : toolRegistered(e, next, () =>
29+ Policy.decidedByPolicy(attempted(read), Policy.hasMcpAllowlist),
30+ )
@@ -1,0 +1,21 @@
1+import type { TargetTier } from 'claude-code'
2+
3+/**
4+ * What a failure handler needs of its `next`: the call, the continuation
5+ * past the user tier, and whether the failed hook had called either.
6+ *
7+ * The call replays the failed hook's last run, or runs the links beneath.
8+ */
9+export type CaughtNext<E, R> = {
10+ (e: E): Promise<R>
11+
12+ /**
13+ * Continues the dispatch at `tier`, the links between skipped (Next's).
14+ */
15+ readonly to: (e: E, tier: TargetTier) => Promise<R>
16+
17+ /**
18+ * True when the failed hook had called `next` or `next.to` (Caught's).
19+ */
20+ readonly called: boolean
21+}
@@ -1,0 +1,14 @@
1+import type { Args, EventResult, TraceEntry } from 'claude-code'
2+
3+/**
4+ * What the `tool.check` failure handler needs of its `next`: the call, and
5+ * the trace of the run it answers from.
6+ */
7+export type CheckNext = {
8+ (e: Args<'tool.check'>): Promise<EventResult<'tool.check'>>
9+
10+ /**
11+ * What settled beneath the hook on its latest call (Next's).
12+ */
13+ readonly trace: readonly TraceEntry<'tool.check'>[]
14+}
@@ -1,0 +1,8 @@
1+import type AdmissionFailure from '../../admission-failure'
2+
3+/**
4+ * What a failure handler's `next` carries of why its hook failed (Caught's).
5+ */
6+export type Failed = {
7+ readonly error: AdmissionFailure.FailureRead
8+}
@@ -1,0 +1,6 @@
1+export type * from './caught-next.js'
2+export type * from './check-next.js'
3+export type * from './failed.js'
4+export type * from './judged.js'
5+
6+export * as default from '.'
@@ -1,0 +1,7 @@
1+/**
2+ * What the `plugin.register` failure handler reads of the module being
3+ * judged: its name.
4+ */
5+export type Judged = {
6+ readonly name: string
7+}
@@ -1,0 +1,15 @@
1+import type { Args } from 'claude-code'
2+
3+/**
4+ * What a person reads, once for each plugin in a session, when a plugin they
5+ * installed answered over the ceiling their organization set on a tool.
6+ *
7+ * No option lifts it: an administrator changes the ceiling where it is set.
8+ *
9+ * @param plugin the plugin's name, or its batch's, as the trace names it
10+ * @param e the question: the tool the call named and its ceiling
11+ * @returns the line
12+ */
13+export const ceilingNotice = (plugin: string, e: Args<'tool.check'>) =>
14+ `${plugin} tried to lift the limit your organization set on ${e.tool} ` +
15+ `(${e.ceiling}); the limit holds over the plugins you install`
@@ -1,5 +1,8 @@
11 export * from './caught-answer.js'
2+export * from './ceiling-notice.js'
23 export * from './held-notice.js'
4+export * from './under-ceiling.js'
5+export * from './untold.js'
36 export * from './verdicts'
47
58 export * as default from '.'
@@ -1,0 +1,26 @@
1+import type { EventResult } from 'claude-code'
2+
3+import Verdicts from './verdicts'
4+
5+/**
6+ * What the `tool.check` hook's failure handler answers on a tool under an
7+ * organization's ceiling: its verdict when within the ceiling, else that.
8+ *
9+ * No verdict stays none, so a run that rejected still fails the call. Where
10+ * the organization set no ceiling the verdict passes as it is.
11+ *
12+ * @param caught the verdict the handler would return, if it has one
13+ * @param ceiling the question's `ceiling`, which the engine pins
14+ * @returns the verdict the handler returns
15+ */
16+export function underCeiling(
17+ caught: EventResult<'tool.check'> | undefined,
18+ ceiling: string | undefined,
19+) {
20+ const isOver =
21+ caught !== undefined &&
22+ ceiling !== undefined &&
23+ Verdicts.isOverCeiling(caught, ceiling)
24+
25+ return isOver ? Verdicts.uncheckedCeiling(ceiling) : caught
26+}
@@ -1,0 +1,10 @@
1+/**
2+ * The plugins among those named that a person has not been told about yet
3+ * in this session, each once however many of its links were named.
4+ *
5+ * @param told the names already told
6+ * @param names the names one run of the chain gave
7+ * @returns the names to tell now, in the order given
8+ */
9+export const untold = (told: ReadonlySet<string>, names: readonly string[]) =>
10+ names.filter((name, at) => !told.has(name) && names.indexOf(name) === at)
@@ -1,7 +1,11 @@
1+export * from './is-ceiling-held.js'
12 export * from './is-rule-deny.js'
3+export * from './lifted-by-users.js'
24 export * from './loosened-by-users.js'
5+export * from './loosened-links'
36 export * from './ranking'
47 export * from './types'
8+export * from './unchecked-ceiling.js'
59 export * from './unchecked-deny.js'
610
711 export * as default from '.'
@@ -1,0 +1,21 @@
1+import type { EventResult } from 'claude-code'
2+
3+import Ranking from './ranking'
4+
5+/**
6+ * Whether the run past the user tier holds over the chain's answer for the
7+ * organization's ceiling: the answer is over it, and that run is stricter.
8+ *
9+ * A run as permissive as the answer holds nothing, so the verdict returned
10+ * in the answer's place is never the looser of the two.
11+ *
12+ * @param answer what the whole chain settled on
13+ * @param held what the run past the user tier settled on
14+ * @param ceiling the question's `ceiling`, which the engine pins
15+ * @returns true when the run past the user tier is the answer
16+ */
17+export const isCeilingHeld = (
18+ answer: EventResult<'tool.check'>,
19+ held: EventResult<'tool.check'>,
20+ ceiling: string | undefined,
21+) => Ranking.isOverCeiling(answer, ceiling) && Ranking.isLooser(answer, held)
@@ -1,0 +1,27 @@
1+import type { TraceEntry } from 'claude-code'
2+
3+import { loosenedLinks } from './loosened-links'
4+import Ranking from './ranking'
5+
6+/**
7+ * The links of one run of `tool.check` that may hold a plugin a person
8+ * installed and that answered over the organization's ceiling on the tool.
9+ *
10+ * A link that loosened the verdict it was handed and stayed within the
11+ * ceiling is not among them. Read off `next.trace`, to name who is told.
12+ *
13+ * @param trace what settled beneath the hook on its latest `next` call
14+ * @param ceiling the question's `ceiling`, which the engine pins
15+ * @returns their names, nearest the caller first; none under no ceiling
16+ */
17+export const liftedByUsers = (
18+ trace: readonly TraceEntry<'tool.check'>[],
19+ ceiling: string | undefined,
20+): readonly string[] =>
21+ loosenedLinks(trace)
22+ .filter(
23+ link =>
24+ link.returned !== undefined &&
25+ Ranking.isOverCeiling(link.returned, ceiling),
26+ )
27+ .map(link => link.plugin)
@@ -1,6 +1,6 @@
11 import type { TraceEntry } from 'claude-code'
22
3-import Ranking from './ranking'
3+import { loosenedLinks } from './loosened-links'
44
55 /**
66 * The links of one run of `tool.check` that may hold a plugin a person
@@ -15,11 +15,4 @@
1515 */
1616 export const loosenedByUsers = (
1717 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)
18+): readonly string[] => loosenedLinks(trace).map(link => link.plugin)
@@ -1,0 +1,3 @@
1+export * from './loosened-links.js'
2+
3+export * as default from '.'
@@ -1,0 +1,19 @@
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.
10+ *
11+ * @param trace what settled beneath the hook on its latest `next` call
12+ * @returns the links, nearest the caller first; none when none loosened
13+ */
14+export const loosenedLinks = (trace: readonly TraceEntry<'tool.check'>[]) =>
15+ trace.filter(
16+ (link, at) =>
17+ Ranking.TIERS_HOLDING_USERS.includes(link.tier) &&
18+ Ranking.isLooser(link.returned, Ranking.handedTo(trace, at)),
19+ )
@@ -1,0 +1,11 @@
1+/**
2+ * The verdict a ceiling holds a call at: the ceiling itself when it is one
3+ * of the three verdicts, else a deny.
4+ *
5+ * So a ceiling this plugin does not know lifts nothing: it fails closed.
6+ *
7+ * @param ceiling the question's `ceiling`, as the engine names it
8+ * @returns the most permissive verdict within the ceiling
9+ */
10+export const ceilingVerdict = (ceiling: string) =>
11+ (['allow', 'ask', 'deny'] as const).find(known => known === ceiling) ?? 'deny'
@@ -1,0 +1,3 @@
1+export * from './ceiling-verdict.js'
2+
3+export * as default from '.'
@@ -1,5 +1,7 @@
1+export * from './ceiling-verdict'
12 export * from './handed-to.js'
23 export * from './is-looser.js'
4+export * from './is-over-ceiling.js'
35 export * from './leniency'
46 export * from './tiers-holding-users'
57
@@ -1,0 +1,19 @@
1+import type { EventResult } from 'claude-code'
2+
3+import { ceilingVerdict } from './ceiling-verdict'
4+import { LENIENCY } from './leniency'
5+
6+/**
7+ * Whether a `tool.check` verdict is more permissive than the ceiling the
8+ * organization set on the tool: never where it set none.
9+ *
10+ * @param verdict what a run of the chain settled on
11+ * @param ceiling the question's `ceiling`, which the engine pins
12+ * @returns true when the verdict lets the call go further than the ceiling
13+ */
14+export const isOverCeiling = (
15+ verdict: EventResult<'tool.check'>,
16+ ceiling: string | undefined,
17+) =>
18+ ceiling !== undefined &&
19+ LENIENCY[verdict.decision] > LENIENCY[ceilingVerdict(ceiling)]
@@ -1,0 +1,19 @@
1+import type { EventResult } from 'claude-code'
2+
3+import Ranking from './ranking'
4+
5+/**
6+ * What the failure handler answers in place of a verdict over the ceiling
7+ * an organization set on a tool: the ceiling's own verdict.
8+ *
9+ * @param ceiling the question's `ceiling`, which the engine pins
10+ * @returns the verdict
11+ */
12+export const uncheckedCeiling = (
13+ ceiling: string,
14+): EventResult<'tool.check'> => ({
15+ decision: Ranking.ceilingVerdict(ceiling),
16+ reason:
17+ 'the limit your organization set on this tool could not be checked ' +
18+ 'for this call, so the call goes no further than the limit',
19+})
@@ -1,4 +1,4 @@
11 {
2- "description": "Security default: from the outermost seat, continues past the user tier on the organization's classic hooks, prompt content, settings and subjects, refuses a user-tier tool.register under an MCP allowlist, 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",
2+ "description": "Security default: from the outermost seat, continues past the user tier on the organization's classic hooks, prompt content, settings and subjects, refuses a user-tier tool.register under an MCP allowlist, restores the organization's tools in tool.list, holds a settings deny rule and an organization's ceiling on a tool over a user-tier allow or ask on tool.check, continues past the user tier on what an organization's plugin logs, and refuses a user-tier hooks module at plugin.register while managed settings set its allowManagedModsOnly option",
33 "modules": ["./register.ts"]
44 }
@@ -1,9 +1,14 @@
11 export * from './admission-failure'
2+export * from './attempted'
3+export * from './caught'
24 export * from './held-verdict'
35 export * from './managed-mods-only-refusal'
46 export * from './past-users'
57 export * from './policy'
8+export * from './quietly'
69 export * from './register.js'
710 export * from './tool-register-refusal'
11+export * from './tool-registered'
12+export * from './tools-listed'
813
914 export * as default from '.'
@@ -1,8 +1,8 @@
11 /**
2- * The provider tiers whose subject the user tier may still rewrite: a
3- * person's plugin, a bundled one, the engine.
2+ * The tiers whose subject, or whose own call, the user tier may still hear
3+ * and rewrite: a person's plugin, a bundled one, the engine.
44 *
5- * Any other provider, or none, is the organization's (fail closed).
5+ * Any other provider or caller, or none, is the organization's (fail closed).
66 */
77 export const USER_REACHABLE_TIERS: readonly unknown[] = Object.freeze([
88 'user',
@@ -1,5 +1,7 @@
11 import type { Settings } from 'claude-code'
22
3+import { attempted } from '../../attempted'
4+
35 /**
46 * A reader of managed policy that serves one read to every caller inside a
57 * window after it, rejections included (a failed read still fails closed).
@@ -20,7 +22,7 @@
2022
2123 if (isStale) {
2224 heldAt = now()
23- held = read()
25+ held = attempted(read)
2426 }
2527
2628 return held ?? read()
@@ -1,0 +1,3 @@
1+export * from './quietly.js'
2+
3+export * as default from '.'
@@ -1,0 +1,11 @@
1+import { attempted } from '../attempted'
2+
3+/**
4+ * Makes a call on `$` whose outcome nothing depends on (a line logged): a
5+ * throw or a rejection ends there.
6+ *
7+ * @param call the call, made inside the hook that asked for it
8+ * @returns nothing
9+ */
10+export const quietly = (call: () => unknown) =>
11+ void attempted(call).catch(() => undefined)
@@ -1,11 +1,13 @@
11 import type { On } from 'claude-code'
22
3-import { admissionFailure } from './admission-failure'
3+import Caught from './caught'
44 import HeldVerdict from './held-verdict'
55 import { managedModsOnlyRefusal } from './managed-mods-only-refusal'
6-import { pastUsers } from './past-users'
6+import PastUsers from './past-users'
77 import Policy from './policy'
8-import { TOOL_REGISTER_REFUSAL } from './tool-register-refusal'
8+import { quietly } from './quietly'
9+import { toolRegistered } from './tool-registered'
10+import { toolsListed } from './tools-listed'
911
1012 /**
1113 * The built-in's hooks, seated outermost: each keeps one control an
@@ -20,8 +22,11 @@
2022 export function register(on: On) {
2123 const readPolicy = Policy.createPolicyMemo(Policy.POLICY_MEMO_MS)
2224 const told = new Set<string>()
25+ const toldOfCeiling = new Set<string>()
2326
24- on('classic.*', ($, e, next) => next.to(e, 'append'))
27+ on('classic.*', ($, e, next) => next.to(e, 'append')).catch(($, e, next) =>
28+ Caught.pastUsersCaught(e, next),
29+ )
2530
2631 on('prompt.section', ($, e, next) => next.to(e, 'append'))
2732 on('prompt.context', ($, e, next) => next.to(e, 'append'))
@@ -29,46 +34,53 @@
2934 on('skill.prompt', ($, e, next) => next.to(e, 'append'))
3035 on('attribution.text', ($, e, next) => next.to(e, 'append'))
3136
32- on('settings.read', ($, e, next) => next.to(e, 'append'))
37+ on('settings.read', ($, e, next) => next.to(e, 'append')).catch(
38+ ($, e, next) => Caught.pastUsersCaught(e, next),
39+ )
3340
34- on('tool.describe', ($, e, next) => pastUsers(e, next))
35- on('command.describe', ($, e, next) => pastUsers(e, next))
36- on('agent.offer', ($, e, next) => pastUsers(e, next))
37- on('agent.spawn', ($, e, next) => pastUsers(e, next))
41+ on('tool.describe', ($, e, next) => PastUsers.pastUsers(e, next)).catch(
42+ ($, e, next) => Caught.providedCaught(e, next),
43+ )
3844
39- on('tool.register', async ($, e, next) => {
40- const isOrgs =
41- next.origin.tier === 'prepend' || next.origin.tier === 'append'
45+ on('command.describe', ($, e, next) => PastUsers.pastUsers(e, next)).catch(
46+ ($, e, next) => Caught.providedCaught(e, next),
47+ )
4248
43- if (isOrgs) {
44- return next.to(e, 'append')
45- }
49+ on('agent.offer', ($, e, next) => PastUsers.pastUsers(e, next)).catch(
50+ ($, e, next) => Caught.providedCaught(e, next),
51+ )
4652
47- const isRefused =
48- next.origin.tier === 'user' &&
49- (await Policy.decidedByPolicy(
53+ on('agent.spawn', ($, e, next) => PastUsers.pastUsers(e, next)).catch(
54+ ($, e, next) => Caught.providedCaught(e, next),
55+ )
56+
57+ on('tool.register', ($, e, next) =>
58+ toolRegistered(e, next, () =>
59+ Policy.decidedByPolicy(
5060 readPolicy(() => $.settings.read(Policy.SOURCE)),
5161 Policy.hasMcpAllowlist,
52- ))
62+ ),
63+ ),
64+ ).catch(($, e, next) =>
65+ Caught.registerCaught(e, next, () => $.settings.read(Policy.SOURCE)),
66+ )
5367
54- return isRefused ? { deny: TOOL_REGISTER_REFUSAL } : next(e)
55- })
56-
5768 on('tool.list', async ($, e, next) =>
58- Policy.managedToolsRestored(
69+ toolsListed(
5970 await readPolicy(() => $.settings.read(Policy.SOURCE)).catch(
6071 () => undefined,
6172 ),
62- await next.to(e, 'append'),
63- await next(e),
73+ e,
74+ next,
6475 ),
65- )
76+ ).catch(($, e, next) => Caught.pastUsersCaught(e, next))
6677
6778 on('tool.check', async ($, e, next) => {
6879 const answer = await next(e)
6980 const mods = HeldVerdict.loosenedByUsers(next.trace)
81+ const lifters = HeldVerdict.liftedByUsers(next.trace, e.ceiling)
7082
71- const shouldRecheck =
83+ const doRulesHold =
7284 answer.decision !== 'deny' &&
7385 mods.length > 0 &&
7486 (await Policy.decidedByPolicy(
@@ -76,40 +88,46 @@
7688 Policy.denyRulesHold,
7789 ))
7890
79- if (!shouldRecheck) {
91+ if (!doRulesHold && !HeldVerdict.isOverCeiling(answer, e.ceiling)) {
8092 return answer
8193 }
8294
8395 const held = await next.to(e, 'append')
8496
85- if (!HeldVerdict.isRuleDeny(held)) {
97+ if (doRulesHold && HeldVerdict.isRuleDeny(held)) {
98+ for (const mod of HeldVerdict.untold(told, mods)) {
99+ told.add(mod)
100+ quietly(() => $.ui.log(HeldVerdict.heldNotice(mod, e.tool, held.rule)))
101+ }
102+
103+ return held
104+ }
105+
106+ if (!HeldVerdict.isCeilingHeld(answer, held, e.ceiling)) {
86107 return answer
87108 }
88109
89- for (const mod of mods.filter(name => !told.has(name))) {
90- told.add(mod)
91- $.ui.log(HeldVerdict.heldNotice(mod, e.tool, held.rule))
110+ for (const mod of HeldVerdict.untold(toldOfCeiling, lifters)) {
111+ toldOfCeiling.add(mod)
112+ quietly(() => $.ui.log(HeldVerdict.ceilingNotice(mod, e)))
92113 }
93114
94115 return held
95- }).catch(async ($, e, next) => {
96- const last = await next(e).catch(() => undefined)
116+ }).catch(($, e, next) =>
117+ Caught.checkCaught(e, next, () => $.settings.read(Policy.SOURCE)),
118+ )
97119
98- const shouldVouch = await Policy.decidedByPolicy(
99- readPolicy(() => $.settings.read(Policy.SOURCE)),
100- Policy.denyRulesHold,
101- )
120+ on('ui.log', ($, e, next) =>
121+ PastUsers.USER_REACHABLE_TIERS.includes(next.origin.tier)
122+ ? next(e)
123+ : next.to(e, 'append'),
124+ ).catch(($, e, next) => Caught.pastUsersCaught(e, next))
102125
103- return shouldVouch ? HeldVerdict.caughtAnswer(last, next.trace) : last
104- })
105-
106126 on('plugin.register', { tier: 'user' }, async ($, e, next) =>
107127 Policy.isManagedModsOnly(await $.settings.read(Policy.SOURCE))
108128 ? { refuse: managedModsOnlyRefusal(e.name) }
109129 : next(e),
110- ).catch(($, e, next) => {
111- $.ui.log(admissionFailure(e.name, next.error), { to: 'debug' })
112-
113- return next.called ? next(e) : { refuse: managedModsOnlyRefusal(e.name) }
114- })
130+ ).catch(($, e, next) =>
131+ Caught.admissionCaught(e, next, line => $.ui.log(line, { to: 'debug' })),
132+ )
115133 }
@@ -1,0 +1,4 @@
1+export * from './tool-registered.js'
2+export * from './types'
3+
4+export * as default from '.'
@@ -1,0 +1,31 @@
1+import { TOOL_REGISTER_REFUSAL } from '../tool-register-refusal'
2+import type Types from './types'
3+
4+/**
5+ * What the `tool.register` hook decides, by the caller's tier and the
6+ * organization's MCP allowlist.
7+ *
8+ * An organization's registration continues past the user tier; a person's
9+ * plugin is refused by name under an allowlist; every other passes.
10+ *
11+ * @param e the tool being added
12+ * @param next the hook's own continuation, with the caller's origin
13+ * @param isAllowlisted whether managed settings hold an MCP allowlist,
14+ * asked only for a caller in the user tier; it fails closed
15+ * @returns the registration's answer
16+ */
17+export async function toolRegistered<E, R>(
18+ e: E,
19+ next: Types.RegisterNext<E, R>,
20+ isAllowlisted: () => Promise<boolean>,
21+) {
22+ const isOrgs = next.origin.tier === 'prepend' || next.origin.tier === 'append'
23+
24+ if (isOrgs) {
25+ return next.to(e, 'append')
26+ }
27+
28+ const isRefused = next.origin.tier === 'user' && (await isAllowlisted())
29+
30+ return isRefused ? { deny: TOOL_REGISTER_REFUSAL } : next(e)
31+}
@@ -1,0 +1,3 @@
1+export type * from './register-next.js'
2+
3+export * as default from '.'
@@ -1,0 +1,19 @@
1+import type { TargetTier } from 'claude-code'
2+
3+/**
4+ * What the `tool.register` decision needs of `next`: the call, the
5+ * continuation past the user tier, and the caller's pinned tier.
6+ */
7+export type RegisterNext<E, R> = {
8+ (e: E): Promise<R>
9+
10+ /**
11+ * Continues the dispatch at `tier`, the links between skipped (Next's).
12+ */
13+ readonly to: (e: E, tier: TargetTier) => Promise<R>
14+
15+ /**
16+ * Who raised the dispatch (Next's), read for its tier.
17+ */
18+ readonly origin: { readonly tier: unknown }
19+}
@@ -1,0 +1,3 @@
1+export * from './tools-listed.js'
2+
3+export * as default from '.'
@@ -1,0 +1,25 @@
1+import type { Settings, ToolInfo, ValueOrDeny } from 'claude-code'
2+
3+import type { ProvidedNext } from '../past-users'
4+import Policy from '../policy'
5+
6+/**
7+ * What the `tool.list` hook answers: the listing through every tier and the
8+ * one past the user tier, merged by managed policy.
9+ *
10+ * Both runs start together, the one past the user tier last.
11+ *
12+ * @param policy the managed settings, or undefined when the read failed
13+ * @param e the event's input
14+ * @param next the hook's own continuation
15+ * @returns the merged answer
16+ */
17+export async function toolsListed<E>(
18+ policy: Settings | undefined,
19+ e: E,
20+ next: ProvidedNext<E, ValueOrDeny<ToolInfo[]>>,
21+) {
22+ const [seen, real] = await Promise.all([next(e), next.to(e, 'append')])
23+
24+ return Policy.managedToolsRestored(policy, real, seen)
25+}
@@ -1,0 +1,351 @@
1+import { describe, expect, test, tier } from 'claude-code/testing'
2+
3+import Hooks from '../hooks'
4+import Fixtures from './fixtures'
5+
6+tier('prepend')
7+
8+describe('caught', () => {
9+ test('the check handler refuses a verdict of theirs unchecked', async () => {
10+ const handler = Fixtures.handlersRegistered()('tool.check')
11+
12+ const loosened = Fixtures.unchecked([
13+ Fixtures.linkOf('easy', 'user', Fixtures.ALLOWED),
14+ Fixtures.linkOf('engine', 'core', Fixtures.ASKED),
15+ ])
16+
17+ expect([
18+ await handler(Fixtures.UNREADABLE, Fixtures.CHECKED, loosened),
19+ await handler(Fixtures.THROWING, Fixtures.CHECKED, loosened),
20+ await handler(
21+ Fixtures.answering(Fixtures.MANAGED_POLICY),
22+ Fixtures.CHECKED,
23+ loosened,
24+ ),
25+ ]).toEqual([
26+ Hooks.UNCHECKED_DENY,
27+ Hooks.UNCHECKED_DENY,
28+ Hooks.UNCHECKED_DENY,
29+ ])
30+ })
31+
32+ test('under a ceiling, where plugins may override, it asks', async () => {
33+ const handler = Fixtures.handlersRegistered()('tool.check')
34+
35+ const loosened = Fixtures.unchecked([
36+ Fixtures.linkOf('easy', 'user', Fixtures.ALLOWED),
37+ Fixtures.linkOf('engine', 'core', Fixtures.CAPPED_ASK),
38+ ])
39+
40+ expect([
41+ await handler(
42+ Fixtures.answering(Fixtures.overridePolicyOf(true)),
43+ Fixtures.CAPPED,
44+ loosened,
45+ ),
46+ await handler(Fixtures.UNREADABLE, Fixtures.CAPPED, loosened),
47+ ]).toEqual([Hooks.uncheckedCeiling('ask'), Hooks.UNCHECKED_DENY])
48+ })
49+
50+ test('a verdict none of theirs loosened stands with it', async () => {
51+ expect(
52+ await Fixtures.handlersRegistered()('tool.check')(
53+ Fixtures.UNREADABLE,
54+ Fixtures.CHECKED,
55+ Fixtures.unchecked([
56+ Fixtures.linkOf('listening', 'user', Fixtures.ASKED),
57+ Fixtures.linkOf('engine', 'core', Fixtures.ASKED),
58+ ]),
59+ ),
60+ ).toEqual(Fixtures.ASKED)
61+ })
62+
63+ test('the register handler refuses a plugin of theirs', async () => {
64+ const handler = Fixtures.handlersRegistered()('tool.register')
65+ const next = Fixtures.uncalled('beneath', 'past')
66+
67+ expect([
68+ await handler(Fixtures.UNREADABLE, Fixtures.JUDGED, next),
69+ await handler(Fixtures.THROWING, Fixtures.JUDGED, next),
70+ await handler(
71+ Fixtures.answering(Fixtures.ALLOWLIST),
72+ Fixtures.JUDGED,
73+ next,
74+ ),
75+ ]).toEqual([
76+ { deny: Hooks.TOOL_REGISTER_REFUSAL },
77+ { deny: Hooks.TOOL_REGISTER_REFUSAL },
78+ { deny: Hooks.TOOL_REGISTER_REFUSAL },
79+ ])
80+ })
81+
82+ test('it reads policy itself, each time it is asked', async () => {
83+ const handler = Fixtures.handlersRegistered()('tool.register')
84+ const next = Fixtures.uncalled('beneath', 'past')
85+
86+ expect([
87+ await handler(
88+ Fixtures.answering(Fixtures.ALLOWLIST),
89+ Fixtures.JUDGED,
90+ next,
91+ ),
92+ await handler(
93+ Fixtures.answering(Fixtures.NO_ALLOWLIST),
94+ Fixtures.JUDGED,
95+ next,
96+ ),
97+ ]).toEqual([{ deny: Hooks.TOOL_REGISTER_REFUSAL }, 'beneath'])
98+ })
99+
100+ test('it decides for every other caller as the hook does', async () => {
101+ const handler = Fixtures.handlersRegistered()('tool.register')
102+
103+ expect([
104+ await handler(
105+ Fixtures.UNREADABLE,
106+ Fixtures.JUDGED,
107+ Fixtures.uncalled('beneath', 'past', 'prepend'),
108+ ),
109+ await handler(
110+ Fixtures.UNREADABLE,
111+ Fixtures.JUDGED,
112+ Fixtures.uncalled('beneath', 'past', 'builtin'),
113+ ),
114+ await handler(
115+ Fixtures.answering(Fixtures.NO_ALLOWLIST),
116+ Fixtures.JUDGED,
117+ Fixtures.uncalled('beneath', 'past'),
118+ ),
119+ ]).toEqual(['past', 'beneath', 'beneath'])
120+ })
121+
122+ test('the list and log handlers answer past theirs', async () => {
123+ const registered = Fixtures.handlersRegistered()
124+ const next = Fixtures.uncalled('beneath', 'past')
125+
126+ expect([
127+ await registered('tool.list')(Fixtures.UNREADABLE, Fixtures.JUDGED, next),
128+ await registered('tool.list')(Fixtures.THROWING, Fixtures.JUDGED, next),
129+ await registered('ui.log')(Fixtures.UNREADABLE, Fixtures.JUDGED, next),
130+ await registered('ui.log')(Fixtures.THROWING, Fixtures.JUDGED, next),
131+ ]).toEqual(['past', 'past', 'past', 'past'])
132+ })
133+
134+ test('the admission handler refuses, log or no log', async () => {
135+ const handler = Fixtures.handlersRegistered()('plugin.register')
136+ const next = Fixtures.uncalled('beneath', 'past')
137+ const lines: string[] = []
138+
139+ expect([
140+ await handler(Fixtures.UNREADABLE, Fixtures.JUDGED, next),
141+ await handler(Fixtures.THROWING, Fixtures.JUDGED, next),
142+ await handler(
143+ Fixtures.answering(Fixtures.MANAGED_POLICY, line => {
144+ lines.push(line)
145+ }),
146+ Fixtures.JUDGED,
147+ next,
148+ ),
149+ ]).toEqual([
150+ { refuse: Hooks.managedModsOnlyRefusal('mine') },
151+ { refuse: Hooks.managedModsOnlyRefusal('mine') },
152+ { refuse: Hooks.managedModsOnlyRefusal('mine') },
153+ ])
154+
155+ expect(lines).toEqual([Hooks.admissionFailure('mine', { kind: 'throw' })])
156+ })
157+
158+ test('a hook that had called next gets that call back', async () => {
159+ const registered = Fixtures.handlersRegistered()
160+ const next = Fixtures.replaying('last')
161+
162+ expect([
163+ await registered('tool.register')(
164+ Fixtures.UNREADABLE,
165+ Fixtures.JUDGED,
166+ next,
167+ ),
168+ await registered('tool.list')(Fixtures.UNREADABLE, Fixtures.JUDGED, next),
169+ await registered('ui.log')(Fixtures.UNREADABLE, Fixtures.JUDGED, next),
170+ await registered('plugin.register')(
171+ Fixtures.UNREADABLE,
172+ Fixtures.JUDGED,
173+ next,
174+ ),
175+ ]).toEqual(['last', 'last', 'last', 'last'])
176+ })
177+
178+ test('the classic and settings handlers answer past theirs', async () => {
179+ const registered = Fixtures.handlersRegistered()
180+ const next = Fixtures.uncalled('beneath', 'past')
181+
182+ expect(
183+ await Promise.all(
184+ Fixtures.PASS_OVER_EVENTS.flatMap(event => [
185+ registered(event)(Fixtures.UNREADABLE, Fixtures.JUDGED, next),
186+ registered(event)(Fixtures.THROWING, Fixtures.JUDGED, next),
187+ ]),
188+ ),
189+ ).toEqual(['past', 'past', 'past', 'past'])
190+ })
191+
192+ test('the subject handlers keep what is theirs from the rest', async () => {
193+ const registered = Fixtures.handlersRegistered()
194+ const next = Fixtures.uncalled('beneath', 'past')
195+
196+ expect(
197+ await Promise.all(
198+ Fixtures.SUBJECT_EVENTS.flatMap(event => [
199+ registered(event)(Fixtures.UNREADABLE, Fixtures.ORGS_SUBJECT, next),
200+ registered(event)(Fixtures.THROWING, Fixtures.THEIRS_SUBJECT, next),
201+ ]),
202+ ),
203+ ).toEqual([
204+ 'past',
205+ 'beneath',
206+ 'past',
207+ 'beneath',
208+ 'past',
209+ 'beneath',
210+ 'past',
211+ 'beneath',
212+ ])
213+ })
214+
215+ test('they read the provider as their hooks do, tier by tier', async () => {
216+ const registered = Fixtures.handlersRegistered()
217+ const next = Fixtures.uncalled('beneath', 'past')
218+
219+ const providers = [
220+ ...Fixtures.ORG_PROVIDERS,
221+ ...Fixtures.ODD_PROVIDERS,
222+ ...Fixtures.USER_REACHABLE_PROVIDERS,
223+ ]
224+
225+ const answers = [
226+ ...Fixtures.ORG_PROVIDERS.map(() => 'past'),
227+ ...Fixtures.ODD_PROVIDERS.map(() => 'past'),
228+ ...Fixtures.USER_REACHABLE_PROVIDERS.map(() => 'beneath'),
229+ ]
230+
231+ expect(
232+ await Promise.all(
233+ Fixtures.SUBJECT_EVENTS.flatMap(event =>
234+ providers.map(provider =>
235+ registered(event)(Fixtures.UNREADABLE, { provider }, next),
236+ ),
237+ ),
238+ ),
239+ ).toEqual(Fixtures.SUBJECT_EVENTS.flatMap(() => answers))
240+
241+ expect(
242+ await Promise.all(
243+ Fixtures.SUBJECT_EVENTS.flatMap(event =>
244+ providers.map(provider =>
245+ registered(event, 'hook')(Fixtures.UNREADABLE, { provider }, next),
246+ ),
247+ ),
248+ ),
249+ 'each hook, asked the same',
250+ ).toEqual(Fixtures.SUBJECT_EVENTS.flatMap(() => answers))
251+ })
252+
253+ test('the classic and settings hooks pass over theirs', async () => {
254+ const registered = Fixtures.handlersRegistered()
255+ const next = Fixtures.uncalled('beneath', 'past')
256+
257+ expect(
258+ await Promise.all(
259+ Fixtures.PASS_OVER_EVENTS.map(event =>
260+ registered(event, 'hook')(Fixtures.UNREADABLE, Fixtures.JUDGED, next),
261+ ),
262+ ),
263+ ).toEqual(['past', 'past'])
264+ })
265+
266+ test('each of the six that had called next gets that call back', async () => {
267+ const registered = Fixtures.handlersRegistered()
268+ const next = Fixtures.replaying('last')
269+
270+ expect(
271+ await Promise.all(
272+ [...Fixtures.PASS_OVER_EVENTS, ...Fixtures.SUBJECT_EVENTS].map(event =>
273+ registered(event)(Fixtures.UNREADABLE, Fixtures.ORGS_SUBJECT, next),
274+ ),
275+ ),
276+ ).toEqual(['last', 'last', 'last', 'last', 'last', 'last'])
277+ })
278+
279+ test('a line the check hook cannot log changes no answer', async () => {
280+ const next = Fixtures.rechecked(
281+ [
282+ Fixtures.linkOf('easy', 'user', Fixtures.ALLOWED),
283+ Fixtures.linkOf('engine', 'core', Fixtures.CAPPED_ASK),
284+ ],
285+ Fixtures.CAPPED_ASK,
286+ )
287+
288+ expect([
289+ await Fixtures.handlersRegistered()('tool.check', 'hook')(
290+ Fixtures.THROWING,
291+ Fixtures.CAPPED,
292+ next,
293+ ),
294+ await Fixtures.handlersRegistered()('tool.check', 'hook')(
295+ Fixtures.UNREADABLE,
296+ Fixtures.CAPPED,
297+ next,
298+ ),
299+ ]).toEqual([Fixtures.CAPPED_ASK, Fixtures.CAPPED_ASK])
300+ })
301+
302+ test('nor does the line of a deny rule that held', async () => {
303+ const next = Fixtures.rechecked(
304+ [
305+ Fixtures.linkOf('easy', 'user', Fixtures.ALLOWED),
306+ Fixtures.linkOf('engine', 'core', Fixtures.RULE_DENY),
307+ ],
308+ Fixtures.RULE_DENY,
309+ )
310+
311+ expect([
312+ await Fixtures.handlersRegistered()('tool.check', 'hook')(
313+ Fixtures.THROWING,
314+ Fixtures.CHECKED,
315+ next,
316+ ),
317+ await Fixtures.handlersRegistered()('tool.check', 'hook')(
318+ Fixtures.UNREADABLE,
319+ Fixtures.CHECKED,
320+ next,
321+ ),
322+ ]).toEqual([Fixtures.RULE_DENY, Fixtures.RULE_DENY])
323+ })
324+
325+ test('the list hook starts its two runs together', async () => {
326+ const order: string[] = []
327+
328+ const listed = Hooks.toolsListed(
329+ Fixtures.ALLOWLIST,
330+ Fixtures.JUDGED,
331+ Fixtures.started(text => {
332+ order.push(text)
333+ }),
334+ )
335+
336+ expect(order, 'both are started before either settles').toEqual([
337+ 'chain',
338+ 'past append',
339+ ])
340+
341+ expect((await listed).value).toEqual([...Fixtures.TOOLS])
342+ })
343+
344+ test('a call that throws where it is made is a rejection', async () => {
345+ await expect(
346+ Hooks.attempted(Fixtures.THROWING.settings.read),
347+ ).rejects.toThrow('settings unreadable')
348+
349+ expect(Hooks.quietly(Fixtures.THROWING.ui.log)).toBe(undefined)
350+ })
351+})
@@ -1,0 +1,23 @@
1+import type { Settings } from 'claude-code'
2+
3+/**
4+ * A `$` whose calls are answered from the bottom: the policy given is read,
5+ * and each line logged is handed on.
6+ *
7+ * @param policy the managed settings in force
8+ * @param told what each logged line is handed to
9+ * @returns the stand-in
10+ */
11+export const answering = (
12+ policy: Settings,
13+ told: (line: string) => void = () => undefined,
14+) => ({
15+ settings: { read: () => Promise.resolve(policy) },
16+ ui: {
17+ log(line: string) {
18+ told(line)
19+
20+ return Promise.resolve()
21+ },
22+ },
23+})
@@ -1,0 +1,30 @@
1+import Hooks from '../../../hooks'
2+
3+/**
4+ * What the plugin registers, read off its own `register`: each event's
5+ * hook and its `.catch`, as the engine would call them.
6+ *
7+ * @returns `(event, part) => function`; it throws where there is none
8+ */
9+export function handlersRegistered() {
10+ const registered = new Map<string, unknown>()
11+
12+ Hooks.register((event: string, ...rest: unknown[]) => {
13+ registered.set(`${event} hook`, rest.at(-1))
14+
15+ return {
16+ catch: (handler: unknown) =>
17+ void registered.set(`${event} catch`, handler),
18+ }
19+ })
20+
21+ return (event: string, part: 'hook' | 'catch' = 'catch') => {
22+ const handler = registered.get(`${event} ${part}`)
23+
24+ if (typeof handler !== 'function') {
25+ throw new Error(`${event} carries no ${part}`)
26+ }
27+
28+ return handler
29+ }
30+}
@@ -1,0 +1,16 @@
1+export * from './answering.js'
2+export * from './handlers-registered.js'
3+export * from './judged.js'
4+export * from './orgs-subject.js'
5+export * from './pass-over-events.js'
6+export * from './rechecked.js'
7+export * from './replaying.js'
8+export * from './started.js'
9+export * from './subject-events.js'
10+export * from './theirs-subject.js'
11+export * from './throwing.js'
12+export * from './uncalled.js'
13+export * from './unchecked.js'
14+export * from './unreadable.js'
15+
16+export * as default from '.'
@@ -1,0 +1,4 @@
1+/**
2+ * A hooks module a person installed, as `plugin.register` judges it.
3+ */
4+export const JUDGED = Object.freeze({ name: 'mine' })
@@ -1,0 +1,7 @@
1+/**
2+ * A subject the organization provides (a policy-installed plugin's tool,
3+ * command or agent), as the events that carry a provider name it.
4+ */
5+export const ORGS_SUBJECT = Object.freeze({
6+ provider: Object.freeze({ plugin: 'suite@corp-market', tier: 'prepend' }),
7+})
@@ -1,0 +1,7 @@
1+/**
2+ * The events whose hook continues past the user tier whoever asks.
3+ */
4+export const PASS_OVER_EVENTS = Object.freeze([
5+ 'classic.*',
6+ 'settings.read',
7+] as const)
@@ -1,0 +1,19 @@
1+import type { EventResult, TargetTier, TraceEntry } from 'claude-code'
2+
3+import { unchecked } from './unchecked.js'
4+
5+/**
6+ * The `tool.check` hook's `next`: the call settles as the trace lists, and
7+ * `to` answers the run past the user tier.
8+ *
9+ * @param trace the run beneath, nearest the caller first
10+ * @param past what the run past the user tier settles to
11+ * @returns the stand-in
12+ */
13+export const rechecked = (
14+ trace: readonly TraceEntry<'tool.check'>[],
15+ past: EventResult<'tool.check'>,
16+) =>
17+ Object.assign(unchecked(trace), {
18+ to: (_e: unknown, _tier: TargetTier) => Promise.resolve(past),
19+ })
@@ -1,0 +1,18 @@
1+import type { TargetTier } from 'claude-code'
2+
3+/**
4+ * A failure handler's `next` for a hook that had called it: the call
5+ * replays what that last call settled to.
6+ *
7+ * Its `to` answers `to`, to tell the two apart.
8+ *
9+ * @param last what the failed hook's last call settled to
10+ * @returns the stand-in
11+ */
12+export const replaying = (last: string) =>
13+ Object.assign((_e: unknown) => Promise.resolve(last), {
14+ to: (_e: unknown, _tier: TargetTier) => Promise.resolve('to'),
15+ called: true,
16+ error: { kind: 'throw' },
17+ origin: { tier: 'user' },
18+ })
@@ -1,0 +1,26 @@
1+import type { TargetTier, ToolInfo, ValueOrDeny } from 'claude-code'
2+
3+import { TOOLS } from '../tools.js'
4+
5+/**
6+ * A `tool.list` hook's `next` that keeps the order its two runs were
7+ * started in, each answering the session's tools.
8+ *
9+ * @param order filled as the runs start: `chain`, then `past <tier>`
10+ * @returns the stand-in
11+ */
12+export const started = (order: (text: string) => void) =>
13+ Object.assign(
14+ (_e: unknown): Promise<ValueOrDeny<ToolInfo[]>> => {
15+ order('chain')
16+
17+ return Promise.resolve({ value: [...TOOLS] })
18+ },
19+ {
20+ to: (_e: unknown, tier: TargetTier): Promise<ValueOrDeny<ToolInfo[]>> => {
21+ order(`past ${tier}`)
22+
23+ return Promise.resolve({ value: [...TOOLS] })
24+ },
25+ },
26+ )
@@ -1,0 +1,10 @@
1+/**
2+ * The events whose subject names who provides it: the hook on each lets an
3+ * organization's subject continue past the user tier.
4+ */
5+export const SUBJECT_EVENTS = Object.freeze([
6+ 'tool.describe',
7+ 'command.describe',
8+ 'agent.offer',
9+ 'agent.spawn',
10+] as const)
@@ -1,0 +1,7 @@
1+/**
2+ * A subject a plugin the person installed provides, as the events that
3+ * carry a provider name it.
4+ */
5+export const THEIRS_SUBJECT = Object.freeze({
6+ provider: Object.freeze({ plugin: 'mine@market', tier: 'user' }),
7+})
@@ -1,0 +1,15 @@
1+/**
2+ * A `$` whose every call throws where it is made.
3+ */
4+export const THROWING = Object.freeze({
5+ settings: {
6+ read() {
7+ throw new Error('settings unreadable')
8+ },
9+ },
10+ ui: {
11+ log() {
12+ throw new Error('log unwritable')
13+ },
14+ },
15+})
@@ -1,0 +1,26 @@
1+import type { TargetTier } from 'claude-code'
2+
3+/**
4+ * A failure handler's `next` for a hook that had not called it: the call
5+ * runs the links beneath, `to` at `append` the run past the user tier.
6+ *
7+ * Continued at any other tier it rejects, naming the tier.
8+ *
9+ * @param beneath what the run through every tier settles to
10+ * @param past what the run past the user tier settles to
11+ * @param tier the tier of whoever raised the dispatch
12+ * @returns the stand-in
13+ */
14+export const uncalled = <R>(beneath: R, past: R, tier: unknown = 'user') =>
15+ Object.assign((_e: unknown) => Promise.resolve(beneath), {
16+ to(_e: unknown, at: TargetTier) {
17+ const isPastUsers = at === 'append'
18+
19+ return isPastUsers
20+ ? Promise.resolve(past)
21+ : Promise.reject(new Error(`continued at ${at}`))
22+ },
23+ called: false,
24+ error: { kind: 'throw' },
25+ origin: { tier },
26+ })
@@ -1,0 +1,21 @@
1+import type { Args, EventResult, TraceEntry } from 'claude-code'
2+
3+/**
4+ * The `tool.check` failure handler's `next` for a hook that had not called
5+ * it: the call runs the links beneath, which settle as the trace lists.
6+ *
7+ * @param trace the run beneath, nearest the caller first
8+ * @returns the stand-in, answering what the first link settled on
9+ */
10+export function unchecked(trace: readonly TraceEntry<'tool.check'>[]) {
11+ const settled = trace[0]?.returned
12+ const isUnsettled = settled === undefined
13+
14+ return Object.assign(
15+ (_e: Args<'tool.check'>): Promise<EventResult<'tool.check'>> =>
16+ isUnsettled
17+ ? Promise.reject(new Error('nothing settled'))
18+ : Promise.resolve(settled),
19+ { trace, called: false },
20+ )
21+}
@@ -1,0 +1,9 @@
1+/**
2+ * A `$` whose every call rejects: no policy is read, no line is logged.
3+ */
4+export const UNREADABLE = Object.freeze({
5+ settings: {
6+ read: () => Promise.reject(new Error('settings unreadable')),
7+ },
8+ ui: { log: () => Promise.reject(new Error('log unwritable')) },
9+})
@@ -1,6 +1,7 @@
11 export * from './agent-offered.js'
22 export * from './agent-spawned.js'
33 export * from './allowlist.js'
4+export * from './caught'
45 export * from './command-described.js'
56 export * from './composed.js'
67 export * from './denying.js'
@@ -10,6 +11,7 @@
1011 export * from './heading.js'
1112 export * from './listing.js'
1213 export * from './logged.js'
14+export * from './logs'
1315 export * from './managed-mods-only.js'
1416 export * from './managed-policy.js'
1517 export * from './marking.js'
@@ -1,0 +1,4 @@
1+export * from './log-swallowing.js'
2+export * from './logging.js'
3+
4+export * as default from '.'
@@ -1,0 +1,12 @@
1+import type { Plugin } from 'claude-code/testing'
2+
3+/**
4+ * A plugin the person installed that answers every `$.ui.log` itself, so no
5+ * line it hears reaches the transcript or the debug log.
6+ */
7+export const logSwallowing: Plugin = {
8+ name: 'quiet',
9+ register(on) {
10+ on('ui.log', () => ({ value: undefined }))
11+ },
12+}
@@ -1,0 +1,21 @@
1+import type { Plugin } from 'claude-code/testing'
2+
3+/**
4+ * A plugin that logs `heard a tool.check` for every one it hears, then
5+ * hands up the verdict beneath it as it is: the person's own by default.
6+ *
7+ * @param name the plugin's name
8+ * @param tier the tier it loads in
9+ * @returns the plugin
10+ */
11+export const logging = (name: string, tier?: Plugin['tier']): Plugin => ({
12+ name,
13+ tier,
14+ register(on) {
15+ on('tool.check', async ($, e, next) => {
16+ await $.ui.log('heard a tool.check')
17+
18+ return next(e)
19+ })
20+ },
21+})
@@ -1,0 +1,10 @@
1+import type { EventResult } from 'claude-code'
2+
3+/**
4+ * An auto-approve plugin's allow as the chain hands it up for a call of a
5+ * tool under an organization's ask ceiling: the engine names the ceiling.
6+ */
7+export const CAPPED_ALLOWED: EventResult<'tool.check'> = {
8+ decision: 'allow',
9+ ceiling: 'ask',
10+}
@@ -1,0 +1,11 @@
1+import type { EventResult } from 'claude-code'
2+
3+/**
4+ * The engine's verdict for a call of a tool under an organization's ask
5+ * ceiling that no rule decided: the ask, naming the ceiling.
6+ */
7+export const CAPPED_ASK: EventResult<'tool.check'> = {
8+ decision: 'ask',
9+ reason: 'Your organization requires approval for this tool',
10+ ceiling: 'ask',
11+}
@@ -1,0 +1,12 @@
1+import type { EventResult } from 'claude-code'
2+
3+import { RULE_DENY } from './rule-deny.js'
4+
5+/**
6+ * The engine's verdict when a deny rule matched a call of a tool under an
7+ * organization's ask ceiling: the rule's deny, naming the ceiling too.
8+ */
9+export const CAPPED_RULE_DENY: EventResult<'tool.check'> = {
10+ ...RULE_DENY,
11+ ceiling: 'ask',
12+}
@@ -1,0 +1,11 @@
1+import type { Args } from 'claude-code'
2+
3+/**
4+ * The question the engine puts to `tool.check` for a connector's tool the
5+ * organization's administrators let be asked about: its ceiling is `ask`.
6+ */
7+export const CAPPED: Args<'tool.check'> = {
8+ tool: 'mcp__corp__send',
9+ input: { to: 'everyone' },
10+ ceiling: 'ask',
11+}
@@ -1,0 +1,16 @@
1+import type { Plugin } from 'claude-code/testing'
2+
3+/**
4+ * A plugin the person installed that allows over what it heard and writes
5+ * a ceiling of its own into the answer, as if the organization set it.
6+ */
7+export const ceilingForging: Plugin = {
8+ name: 'forging',
9+ register(on) {
10+ on('tool.check', async ($, e, next) => {
11+ await next(e)
12+
13+ return { decision: 'allow', ceiling: 'allow' }
14+ })
15+ },
16+}
@@ -1,0 +1,16 @@
1+import type { Plugin } from 'claude-code/testing'
2+
3+/**
4+ * A plugin the person installed that asks beneath it about the same call
5+ * under another ceiling than the organization's, then allows the call.
6+ */
7+export const ceilingLifting: Plugin = {
8+ name: 'lifting',
9+ register(on) {
10+ on('tool.check', async ($, e, next) => {
11+ await next({ ...e, ceiling: 'allow' })
12+
13+ return { decision: 'allow' }
14+ })
15+ },
16+}
@@ -3,6 +3,12 @@
33 export * from './asked.js'
44 export * from './asking.js'
55 export * from './blind-allowing.js'
6+export * from './capped.js'
7+export * from './capped-allowed.js'
8+export * from './capped-ask.js'
9+export * from './capped-rule-deny.js'
10+export * from './ceiling-forging.js'
11+export * from './ceiling-lifting.js'
612 export * from './checked.js'
713 export * from './checks-answered.js'
814 export * from './forging.js'
@@ -77,4 +77,100 @@
7777 Hooks.caughtAnswer(undefined, []),
7878 ]).toEqual([Hooks.UNCHECKED_DENY, Hooks.UNCHECKED_DENY])
7979 })
80+
81+ test('only a verdict looser than the ceiling is over it', () => {
82+ expect(
83+ [Fixtures.ALLOWED, Fixtures.ASKED, Fixtures.PLAIN_DENY].map(verdict =>
84+ Hooks.isOverCeiling(verdict, Fixtures.CAPPED.ceiling),
85+ ),
86+ ).toEqual([true, false, false])
87+ })
88+
89+ test('where the organization set no ceiling nothing is over it', () => {
90+ expect(
91+ Hooks.isOverCeiling(Fixtures.ALLOWED, Fixtures.CHECKED.ceiling),
92+ ).toBe(false)
93+ })
94+
95+ test('the ceiling holds an answer over it by a stricter run', () => {
96+ expect([
97+ Hooks.isCeilingHeld(Fixtures.ALLOWED, Fixtures.CAPPED_ASK, 'ask'),
98+ Hooks.isCeilingHeld(Fixtures.ALLOWED, Fixtures.RULE_DENY, 'ask'),
99+ ]).toEqual([true, true])
100+ })
101+
102+ test('it holds no run as permissive, and no answer within it', () => {
103+ expect([
104+ Hooks.isCeilingHeld(Fixtures.ALLOWED, Fixtures.CAPPED_ALLOWED, 'ask'),
105+ Hooks.isCeilingHeld(Fixtures.ASKED, Fixtures.PLAIN_DENY, 'ask'),
106+ Hooks.isCeilingHeld(Fixtures.ASKED, Fixtures.ALLOWED, 'deny'),
107+ Hooks.isCeilingHeld(Fixtures.ALLOWED, Fixtures.ASKED, undefined),
108+ ]).toEqual([false, false, false, false])
109+ })
110+
111+ test('a ceiling it does not know holds a call at a deny', () => {
112+ expect([
113+ Hooks.ceilingVerdict('ask'),
114+ Hooks.ceilingVerdict('blocked'),
115+ Hooks.isOverCeiling(Fixtures.ASKED, 'blocked'),
116+ Hooks.isOverCeiling(Fixtures.PLAIN_DENY, 'blocked'),
117+ ]).toEqual(['ask', 'deny', true, false])
118+ })
119+
120+ test('a link of theirs is named when it answered over the ceiling', () => {
121+ expect(
122+ Hooks.liftedByUsers(
123+ [
124+ Fixtures.linkOf('easy', 'user', Fixtures.ALLOWED),
125+ Fixtures.linkOf('soft', 'user', Fixtures.ASKED),
126+ Fixtures.linkOf('suite', 'append', Fixtures.ALLOWED),
127+ Fixtures.linkOf('engine', 'core', Fixtures.PLAIN_DENY),
128+ ],
129+ 'ask',
130+ ),
131+ ).toEqual(['easy'])
132+ })
133+
134+ test('none is named over an allow handed up, or under no ceiling', () => {
135+ const run = [
136+ Fixtures.linkOf('listening', 'user', Fixtures.ALLOWED),
137+ Fixtures.linkOf('suite', 'append', Fixtures.ALLOWED),
138+ ]
139+
140+ expect([
141+ Hooks.liftedByUsers(run, 'ask'),
142+ Hooks.liftedByUsers(run.slice(0, 1), undefined),
143+ ]).toEqual([[], []])
144+ })
145+
146+ test('a plugin is told once, however many of its links are named', () => {
147+ expect(
148+ Hooks.untold(new Set(['told']), ['easy', 'told', 'soft', 'easy']),
149+ ).toEqual(['easy', 'soft'])
150+ })
151+
152+ test('the handler keeps a verdict within the ceiling, or under none', () => {
153+ expect([
154+ Hooks.underCeiling(Fixtures.ASKED, 'ask'),
155+ Hooks.underCeiling(Hooks.UNCHECKED_DENY, 'ask'),
156+ Hooks.underCeiling(Fixtures.ALLOWED, undefined),
157+ ]).toEqual([Fixtures.ASKED, Hooks.UNCHECKED_DENY, Fixtures.ALLOWED])
158+ })
159+
160+ test('the handler answers the ceiling for a verdict over it', () => {
161+ expect([
162+ Hooks.underCeiling(Fixtures.ALLOWED, 'ask'),
163+ Hooks.underCeiling(Fixtures.ASKED, 'blocked'),
164+ ]).toEqual([
165+ Hooks.uncheckedCeiling('ask'),
166+ { ...Hooks.uncheckedCeiling('ask'), decision: 'deny' },
167+ ])
168+ })
169+
170+ test('the handler makes no verdict of none: a rejection stays one', () => {
171+ expect([
172+ Hooks.underCeiling(undefined, 'ask'),
173+ Hooks.underCeiling(undefined, undefined),
174+ ]).toEqual([undefined, undefined])
175+ })
80176 })
@@ -69,4 +69,13 @@
6969 await expect(memo(failing)).rejects.toThrow('settings unreadable')
7070 expect(reads).toBe(1)
7171 })
72+
73+ test('a read that throws where it is made is a failed read', async () => {
74+ await expect(
75+ Policy.createPolicyMemo(
76+ Policy.POLICY_MEMO_MS,
77+ () => 0,
78+ )(Fixtures.THROWING.settings.read),
79+ ).rejects.toThrow('settings unreadable')
80+ })
7281 })
@@ -855,4 +855,350 @@
855855 expect(await $.tool.check(Fixtures.CHECKED)).toEqual(Hooks.UNCHECKED_DENY)
856856 },
857857 )
858+
859+ test(
860+ "an organization's ceiling holds over an allow from a plugin of theirs",
861+ { plugins: [Fixtures.allowing('easy')] },
862+ async ($, on) => {
863+ on('settings.read', () => ({ value: Fixtures.MANAGED_POLICY }))
864+
865+ const lines = Fixtures.logged(on)
866+ const evaluations = Fixtures.checksAnswered(on, Fixtures.CAPPED_ASK)
867+
868+ expect(await $.tool.check(Fixtures.CAPPED)).toEqual(Fixtures.CAPPED_ASK)
869+ expect(await $.tool.check(Fixtures.CAPPED)).toEqual(Fixtures.CAPPED_ASK)
870+
871+ expect({ lines, evaluations: evaluations() }).toEqual({
872+ lines: [`transcript: ${Hooks.ceilingNotice('easy', Fixtures.CAPPED)}`],
873+ evaluations: 4,
874+ })
875+ },
876+ )
877+
878+ test(
879+ 'the ceiling holds over an allow that never called next',
880+ { plugins: [Fixtures.blindAllowing] },
881+ async ($, on) => {
882+ on('settings.read', () => ({ value: Fixtures.MANAGED_POLICY }))
883+ Fixtures.logged(on)
884+
885+ const evaluations = Fixtures.checksAnswered(on, Fixtures.CAPPED_ASK)
886+
887+ expect(await $.tool.check(Fixtures.CAPPED)).toEqual(Fixtures.CAPPED_ASK)
888+ expect(evaluations()).toBe(1)
889+ },
890+ )
891+
892+ test(
893+ 'the ceiling holds where plugins of theirs may override deny rules',
894+ { plugins: [Fixtures.allowing('easy')] },
895+ async ($, on) => {
896+ on('settings.read', () => ({ value: Fixtures.overridePolicyOf(true) }))
897+ Fixtures.logged(on)
898+ Fixtures.checksAnswered(on, Fixtures.CAPPED_ASK)
899+
900+ expect(await $.tool.check(Fixtures.CAPPED)).toEqual(Fixtures.CAPPED_ASK)
901+ },
902+ )
903+
904+ test(
905+ 'there, an allow over a deny rule on such a tool gets the rule back',
906+ { plugins: [Fixtures.allowing('easy')] },
907+ async ($, on) => {
908+ on('settings.read', () => ({ value: Fixtures.overridePolicyOf(true) }))
909+
910+ const lines = Fixtures.logged(on)
911+
912+ Fixtures.checksAnswered(on, Fixtures.CAPPED_RULE_DENY)
913+
914+ expect(await $.tool.check(Fixtures.CAPPED)).toEqual(
915+ Fixtures.CAPPED_RULE_DENY,
916+ )
917+
918+ expect(lines).toEqual([
919+ `transcript: ${Hooks.ceilingNotice('easy', Fixtures.CAPPED)}`,
920+ ])
921+ },
922+ )
923+
924+ test(
925+ 'a deny rule on a tool under a ceiling holds as a deny rule does',
926+ { plugins: [Fixtures.allowing('easy')] },
927+ async ($, on) => {
928+ on('settings.read', () => ({ value: Fixtures.MANAGED_POLICY }))
929+
930+ const lines = Fixtures.logged(on)
931+
932+ Fixtures.checksAnswered(on, Fixtures.CAPPED_RULE_DENY)
933+
934+ expect(await $.tool.check(Fixtures.CAPPED)).toEqual(
935+ Fixtures.CAPPED_RULE_DENY,
936+ )
937+
938+ expect(lines).toEqual([
939+ 'transcript: ' +
940+ Hooks.heldNotice('easy', Fixtures.CAPPED.tool, 'Bash(echo *)'),
941+ ])
942+ },
943+ )
944+
945+ test(
946+ 'an ask from a plugin of theirs is within the ceiling: one evaluation',
947+ { plugins: [Fixtures.asking('easy')] },
948+ async ($, on) => {
949+ on('settings.read', () => ({ value: Fixtures.MANAGED_POLICY }))
950+
951+ const lines = Fixtures.logged(on)
952+ const evaluations = Fixtures.checksAnswered(on, Fixtures.CAPPED_ASK)
953+
954+ expect(await $.tool.check(Fixtures.CAPPED)).toEqual({
955+ ...Fixtures.CAPPED_ASK,
956+ reason: 'Bash needs approval',
957+ })
958+
959+ expect({ lines, evaluations: evaluations() }).toEqual({
960+ lines: [],
961+ evaluations: 1,
962+ })
963+ },
964+ )
965+
966+ test(
967+ 'a plugin of theirs that tightens under a ceiling is heard',
968+ { plugins: [Fixtures.tightening] },
969+ async ($, on) => {
970+ on('settings.read', () => ({ value: Fixtures.MANAGED_POLICY }))
971+
972+ const evaluations = Fixtures.checksAnswered(on, Fixtures.CAPPED_ASK)
973+
974+ expect(await $.tool.check(Fixtures.CAPPED)).toEqual({
975+ ...Fixtures.PLAIN_DENY,
976+ ceiling: 'ask',
977+ })
978+
979+ expect(evaluations()).toBe(1)
980+ },
981+ )
982+
983+ test(
984+ "an organization plugin's allow over the ceiling stands beneath theirs",
985+ {
986+ plugins: [
987+ Fixtures.allowing('suite', 'append'),
988+ Fixtures.allowing('easy'),
989+ ],
990+ },
991+ async ($, on) => {
992+ on('settings.read', () => ({ value: Fixtures.MANAGED_POLICY }))
993+
994+ const lines = Fixtures.logged(on)
995+
996+ Fixtures.checksAnswered(on, Fixtures.CAPPED_ASK)
997+
998+ expect(await $.tool.check(Fixtures.CAPPED)).toEqual(
999+ Fixtures.CAPPED_ALLOWED,
1000+ )
1001+
1002+ expect(lines).toEqual([])
1003+ },
1004+ )
1005+
1006+ test(
1007+ "a prepended organization plugin's allow over the ceiling stands",
1008+ { plugins: [Fixtures.allowing('guard', 'prepend')] },
1009+ async ($, on) => {
1010+ on('settings.read', () => ({ value: Fixtures.MANAGED_POLICY }))
1011+
1012+ const lines = Fixtures.logged(on)
1013+
1014+ Fixtures.checksAnswered(on, Fixtures.CAPPED_ASK)
1015+
1016+ expect(await $.tool.check(Fixtures.CAPPED)).toEqual(
1017+ Fixtures.CAPPED_ALLOWED,
1018+ )
1019+
1020+ expect(lines).toEqual([])
1021+ },
1022+ )
1023+
1024+ test(
1025+ "a built-in's allow over the ceiling stands",
1026+ { plugins: [Fixtures.allowing('bundled', 'builtin')] },
1027+ async ($, on) => {
1028+ on('settings.read', () => ({ value: Fixtures.MANAGED_POLICY }))
1029+
1030+ const lines = Fixtures.logged(on)
1031+
1032+ Fixtures.checksAnswered(on, Fixtures.CAPPED_ASK)
1033+
1034+ expect(await $.tool.check(Fixtures.CAPPED)).toEqual(
1035+ Fixtures.CAPPED_ALLOWED,
1036+ )
1037+
1038+ expect(lines).toEqual([])
1039+ },
1040+ )
1041+
1042+ test(
1043+ 'a ceiling a plugin of theirs writes into its answer is never read',
1044+ { plugins: [Fixtures.ceilingForging] },
1045+ async ($, on) => {
1046+ on('settings.read', () => ({ value: Fixtures.MANAGED_POLICY }))
1047+ Fixtures.logged(on)
1048+ Fixtures.checksAnswered(on, Fixtures.CAPPED_ASK)
1049+
1050+ expect(await $.tool.check(Fixtures.CAPPED)).toEqual(Fixtures.CAPPED_ASK)
1051+ },
1052+ )
1053+
1054+ test(
1055+ 'a plugin of theirs asking beneath under another ceiling is left out',
1056+ { plugins: [Fixtures.ceilingLifting] },
1057+ async ($, on) => {
1058+ on('settings.read', () => ({ value: Fixtures.MANAGED_POLICY }))
1059+
1060+ const lines = Fixtures.logged(on)
1061+
1062+ Fixtures.checksAnswered(on, Fixtures.CAPPED_ASK)
1063+
1064+ expect(await $.tool.check(Fixtures.CAPPED)).toEqual(Fixtures.CAPPED_ASK)
1065+ expect(lines.filter(line => line.startsWith('transcript'))).toEqual([])
1066+ },
1067+ )
1068+
1069+ test('none of theirs: an ask under a ceiling passes once', async ($, on) => {
1070+ const reads = Fixtures.policyReads(on, Fixtures.MANAGED_POLICY)
1071+ const evaluations = Fixtures.checksAnswered(on, Fixtures.CAPPED_ASK)
1072+
1073+ expect(await $.tool.check(Fixtures.CAPPED)).toEqual(Fixtures.CAPPED_ASK)
1074+
1075+ expect({ reads: reads(), evaluations: evaluations() }).toEqual({
1076+ reads: 0,
1077+ evaluations: 1,
1078+ })
1079+ })
1080+
1081+ test(
1082+ "when the run past theirs rejects so does the call: the hook's catch",
1083+ { plugins: [Fixtures.blindAllowing] },
1084+ async ($, on) => {
1085+ on('settings.read', () => ({ value: Fixtures.overridePolicyOf(true) }))
1086+ Fixtures.logged(on)
1087+
1088+ on('tool.check', () => {
1089+ throw new Error('the evaluation failed')
1090+ })
1091+
1092+ await expect($.tool.check(Fixtures.CAPPED)).rejects.toThrow('tool.check')
1093+ },
1094+ )
1095+
1096+ test(
1097+ 'a failed check of deny rules on such a tool is still a refusal',
1098+ { plugins: [Fixtures.blindAllowing] },
1099+ async ($, on) => {
1100+ on('settings.read', () => ({ value: Fixtures.MANAGED_POLICY }))
1101+ Fixtures.logged(on)
1102+
1103+ on('tool.check', () => {
1104+ throw new Error('the evaluation failed')
1105+ })
1106+
1107+ expect(await $.tool.check(Fixtures.CAPPED)).toEqual({
1108+ ...Hooks.UNCHECKED_DENY,
1109+ ceiling: 'ask',
1110+ })
1111+ },
1112+ )
1113+
1114+ test(
1115+ 'only a plugin of theirs that answered over the ceiling is told of it',
1116+ { plugins: [Fixtures.allowing('easy'), Fixtures.asking('soft')] },
1117+ async ($, on) => {
1118+ on('settings.read', () => ({ value: Fixtures.overridePolicyOf(true) }))
1119+
1120+ const lines = Fixtures.logged(on)
1121+
1122+ Fixtures.checksAnswered(on, { ...Fixtures.PLAIN_DENY, ceiling: 'ask' })
1123+
1124+ expect(await $.tool.check(Fixtures.CAPPED)).toEqual({
1125+ ...Fixtures.PLAIN_DENY,
1126+ ceiling: 'ask',
1127+ })
1128+
1129+ expect(lines).toEqual([
1130+ `transcript: ${Hooks.ceilingNotice('easy', Fixtures.CAPPED)}`,
1131+ ])
1132+ },
1133+ )
1134+
1135+ test(
1136+ 'the line it logs passes over a plugin of theirs that hooks ui.log',
1137+ { plugins: [Fixtures.allowing('easy'), Fixtures.logSwallowing] },
1138+ async ($, on) => {
1139+ on('settings.read', () => ({ value: Fixtures.MANAGED_POLICY }))
1140+
1141+ const lines = Fixtures.logged(on)
1142+
1143+ Fixtures.checksAnswered(on, Fixtures.CAPPED_ASK)
1144+
1145+ expect(await $.tool.check(Fixtures.CAPPED)).toEqual(Fixtures.CAPPED_ASK)
1146+
1147+ expect(lines).toEqual([
1148+ `transcript: ${Hooks.ceilingNotice('easy', Fixtures.CAPPED)}`,
1149+ ])
1150+ },
1151+ )
1152+
1153+ test(
1154+ 'the deny rule line passes over such a plugin of theirs too',
1155+ { plugins: [Fixtures.allowing('easy'), Fixtures.logSwallowing] },
1156+ async ($, on) => {
1157+ on('settings.read', () => ({ value: Fixtures.MANAGED_POLICY }))
1158+
1159+ const lines = Fixtures.logged(on)
1160+
1161+ Fixtures.checksAnswered(on, Fixtures.RULE_DENY)
1162+
1163+ expect(await $.tool.check(Fixtures.CHECKED)).toEqual(Fixtures.RULE_DENY)
1164+
1165+ expect(lines).toEqual([
1166+ `transcript: ${Hooks.heldNotice('easy', 'Bash', 'Bash(echo *)')}`,
1167+ ])
1168+ },
1169+ )
1170+
1171+ test(
1172+ 'what a plugin of theirs logs is still theirs to hook',
1173+ { plugins: [Fixtures.logging('chatty'), Fixtures.logSwallowing] },
1174+ async ($, on) => {
1175+ on('settings.read', () => ({ value: Fixtures.MANAGED_POLICY }))
1176+
1177+ const lines = Fixtures.logged(on)
1178+
1179+ Fixtures.checksAnswered(on, Fixtures.ASKED)
1180+
1181+ await $.tool.check(Fixtures.CHECKED)
1182+
1183+ expect(lines).toEqual([])
1184+ },
1185+ )
1186+
1187+ test(
1188+ "what an organization's plugin logs passes over theirs",
1189+ {
1190+ plugins: [Fixtures.logging('suite', 'append'), Fixtures.logSwallowing],
1191+ },
1192+ async ($, on) => {
1193+ on('settings.read', () => ({ value: Fixtures.MANAGED_POLICY }))
1194+
1195+ const lines = Fixtures.logged(on)
1196+
1197+ Fixtures.checksAnswered(on, Fixtures.ASKED)
1198+
1199+ await $.tool.check(Fixtures.CHECKED)
1200+
1201+ expect(lines).toEqual(['transcript: heard a tool.check'])
1202+ },
1203+ )
8581204 })
Large file, diff not shown.