From 318196e284b022ccc0ad791fce7ede9819b8ff77 Mon Sep 17 00:00:00 2001 From: Alice Poteat Date: Sun, 4 Oct 2026 18:03:52 -0700 Subject: [PATCH] sec-default: an organization's ceiling on a tool holds over the plugins a person installs 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. --- mods/sec-default/README.md | 111 +++++- .../admission-failure/admission-failure.ts | 4 +- .../failure-read/failure-read.ts | 7 + .../admission-failure/failure-read/index.ts | 3 + .../hooks/admission-failure/index.ts | 1 + mods/sec-default/hooks/attempted/attempted.ts | 9 + mods/sec-default/hooks/attempted/index.ts | 3 + .../hooks/caught/admission-caught.ts | 26 ++ mods/sec-default/hooks/caught/check-caught.ts | 37 ++ mods/sec-default/hooks/caught/index.ts | 8 + .../hooks/caught/past-users-caught.ts | 15 + .../hooks/caught/provided-caught.ts | 19 + .../hooks/caught/register-caught.ts | 30 ++ .../hooks/caught/types/caught-next.ts | 21 ++ .../hooks/caught/types/check-next.ts | 14 + mods/sec-default/hooks/caught/types/failed.ts | 8 + mods/sec-default/hooks/caught/types/index.ts | 6 + mods/sec-default/hooks/caught/types/judged.ts | 7 + .../hooks/held-verdict/ceiling-notice.ts | 15 + mods/sec-default/hooks/held-verdict/index.ts | 3 + .../hooks/held-verdict/under-ceiling.ts | 26 ++ mods/sec-default/hooks/held-verdict/untold.ts | 10 + .../hooks/held-verdict/verdicts/index.ts | 4 + .../held-verdict/verdicts/is-ceiling-held.ts | 21 ++ .../held-verdict/verdicts/lifted-by-users.ts | 27 ++ .../verdicts/loosened-by-users.ts | 11 +- .../verdicts/loosened-links/index.ts | 3 + .../verdicts/loosened-links/loosened-links.ts | 19 + .../ceiling-verdict/ceiling-verdict.ts | 11 + .../verdicts/ranking/ceiling-verdict/index.ts | 3 + .../held-verdict/verdicts/ranking/index.ts | 2 + .../verdicts/ranking/is-over-ceiling.ts | 19 + .../verdicts/unchecked-ceiling.ts | 19 + mods/sec-default/hooks/hooks.json | 2 +- mods/sec-default/hooks/index.ts | 5 + .../user-reachable-tiers.ts | 6 +- .../create-policy-memo/create-policy-memo.ts | 4 +- mods/sec-default/hooks/quietly/index.ts | 3 + mods/sec-default/hooks/quietly/quietly.ts | 11 + mods/sec-default/hooks/register.ts | 110 +++--- .../hooks/tool-registered/index.ts | 4 + .../hooks/tool-registered/tool-registered.ts | 31 ++ .../hooks/tool-registered/types/index.ts | 3 + .../tool-registered/types/register-next.ts | 19 + mods/sec-default/hooks/tools-listed/index.ts | 3 + .../hooks/tools-listed/tools-listed.ts | 25 ++ mods/sec-default/tests/caught.test.ts | 351 ++++++++++++++++++ .../tests/fixtures/caught/answering.ts | 23 ++ .../fixtures/caught/handlers-registered.ts | 30 ++ .../tests/fixtures/caught/index.ts | 16 + .../tests/fixtures/caught/judged.ts | 4 + .../tests/fixtures/caught/orgs-subject.ts | 7 + .../tests/fixtures/caught/pass-over-events.ts | 7 + .../tests/fixtures/caught/rechecked.ts | 19 + .../tests/fixtures/caught/replaying.ts | 18 + .../tests/fixtures/caught/started.ts | 26 ++ .../tests/fixtures/caught/subject-events.ts | 10 + .../tests/fixtures/caught/theirs-subject.ts | 7 + .../tests/fixtures/caught/throwing.ts | 15 + .../tests/fixtures/caught/uncalled.ts | 26 ++ .../tests/fixtures/caught/unchecked.ts | 21 ++ .../tests/fixtures/caught/unreadable.ts | 9 + mods/sec-default/tests/fixtures/index.ts | 2 + mods/sec-default/tests/fixtures/logs/index.ts | 4 + .../tests/fixtures/logs/log-swallowing.ts | 12 + .../tests/fixtures/logs/logging.ts | 21 ++ .../fixtures/tool-check/capped-allowed.ts | 10 + .../tests/fixtures/tool-check/capped-ask.ts | 11 + .../fixtures/tool-check/capped-rule-deny.ts | 12 + .../tests/fixtures/tool-check/capped.ts | 11 + .../fixtures/tool-check/ceiling-forging.ts | 16 + .../fixtures/tool-check/ceiling-lifting.ts | 16 + .../tests/fixtures/tool-check/index.ts | 6 + mods/sec-default/tests/held-verdict.test.ts | 96 +++++ .../tests/policy/create-policy-memo.test.ts | 9 + mods/sec-default/tests/register.test.ts | 346 +++++++++++++++++ mods/types/claude-code.d.ts | 34 +- 77 files changed, 1858 insertions(+), 85 deletions(-) create mode 100644 mods/sec-default/hooks/admission-failure/failure-read/failure-read.ts create mode 100644 mods/sec-default/hooks/admission-failure/failure-read/index.ts create mode 100644 mods/sec-default/hooks/attempted/attempted.ts create mode 100644 mods/sec-default/hooks/attempted/index.ts create mode 100644 mods/sec-default/hooks/caught/admission-caught.ts create mode 100644 mods/sec-default/hooks/caught/check-caught.ts create mode 100644 mods/sec-default/hooks/caught/index.ts create mode 100644 mods/sec-default/hooks/caught/past-users-caught.ts create mode 100644 mods/sec-default/hooks/caught/provided-caught.ts create mode 100644 mods/sec-default/hooks/caught/register-caught.ts create mode 100644 mods/sec-default/hooks/caught/types/caught-next.ts create mode 100644 mods/sec-default/hooks/caught/types/check-next.ts create mode 100644 mods/sec-default/hooks/caught/types/failed.ts create mode 100644 mods/sec-default/hooks/caught/types/index.ts create mode 100644 mods/sec-default/hooks/caught/types/judged.ts create mode 100644 mods/sec-default/hooks/held-verdict/ceiling-notice.ts create mode 100644 mods/sec-default/hooks/held-verdict/under-ceiling.ts create mode 100644 mods/sec-default/hooks/held-verdict/untold.ts create mode 100644 mods/sec-default/hooks/held-verdict/verdicts/is-ceiling-held.ts create mode 100644 mods/sec-default/hooks/held-verdict/verdicts/lifted-by-users.ts create mode 100644 mods/sec-default/hooks/held-verdict/verdicts/loosened-links/index.ts create mode 100644 mods/sec-default/hooks/held-verdict/verdicts/loosened-links/loosened-links.ts create mode 100644 mods/sec-default/hooks/held-verdict/verdicts/ranking/ceiling-verdict/ceiling-verdict.ts create mode 100644 mods/sec-default/hooks/held-verdict/verdicts/ranking/ceiling-verdict/index.ts create mode 100644 mods/sec-default/hooks/held-verdict/verdicts/ranking/is-over-ceiling.ts create mode 100644 mods/sec-default/hooks/held-verdict/verdicts/unchecked-ceiling.ts create mode 100644 mods/sec-default/hooks/quietly/index.ts create mode 100644 mods/sec-default/hooks/quietly/quietly.ts create mode 100644 mods/sec-default/hooks/tool-registered/index.ts create mode 100644 mods/sec-default/hooks/tool-registered/tool-registered.ts create mode 100644 mods/sec-default/hooks/tool-registered/types/index.ts create mode 100644 mods/sec-default/hooks/tool-registered/types/register-next.ts create mode 100644 mods/sec-default/hooks/tools-listed/index.ts create mode 100644 mods/sec-default/hooks/tools-listed/tools-listed.ts create mode 100644 mods/sec-default/tests/caught.test.ts create mode 100644 mods/sec-default/tests/fixtures/caught/answering.ts create mode 100644 mods/sec-default/tests/fixtures/caught/handlers-registered.ts create mode 100644 mods/sec-default/tests/fixtures/caught/index.ts create mode 100644 mods/sec-default/tests/fixtures/caught/judged.ts create mode 100644 mods/sec-default/tests/fixtures/caught/orgs-subject.ts create mode 100644 mods/sec-default/tests/fixtures/caught/pass-over-events.ts create mode 100644 mods/sec-default/tests/fixtures/caught/rechecked.ts create mode 100644 mods/sec-default/tests/fixtures/caught/replaying.ts create mode 100644 mods/sec-default/tests/fixtures/caught/started.ts create mode 100644 mods/sec-default/tests/fixtures/caught/subject-events.ts create mode 100644 mods/sec-default/tests/fixtures/caught/theirs-subject.ts create mode 100644 mods/sec-default/tests/fixtures/caught/throwing.ts create mode 100644 mods/sec-default/tests/fixtures/caught/uncalled.ts create mode 100644 mods/sec-default/tests/fixtures/caught/unchecked.ts create mode 100644 mods/sec-default/tests/fixtures/caught/unreadable.ts create mode 100644 mods/sec-default/tests/fixtures/logs/index.ts create mode 100644 mods/sec-default/tests/fixtures/logs/log-swallowing.ts create mode 100644 mods/sec-default/tests/fixtures/logs/logging.ts create mode 100644 mods/sec-default/tests/fixtures/tool-check/capped-allowed.ts create mode 100644 mods/sec-default/tests/fixtures/tool-check/capped-ask.ts create mode 100644 mods/sec-default/tests/fixtures/tool-check/capped-rule-deny.ts create mode 100644 mods/sec-default/tests/fixtures/tool-check/capped.ts create mode 100644 mods/sec-default/tests/fixtures/tool-check/ceiling-forging.ts create mode 100644 mods/sec-default/tests/fixtures/tool-check/ceiling-lifting.ts diff --git a/mods/sec-default/README.md b/mods/sec-default/README.md index 98bbc1c2c2..e86ddb4fe7 100644 --- a/mods/sec-default/README.md +++ b/mods/sec-default/README.md @@ -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 @@ -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. @@ -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 @@ -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. @@ -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. @@ -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: ` tried to lift the limit your organization + set on (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 diff --git a/mods/sec-default/hooks/admission-failure/admission-failure.ts b/mods/sec-default/hooks/admission-failure/admission-failure.ts index ae07b4b05b..bef06f74fd 100644 --- a/mods/sec-default/hooks/admission-failure/admission-failure.ts +++ b/mods/sec-default/hooks/admission-failure/admission-failure.ts @@ -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: @@ -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') diff --git a/mods/sec-default/hooks/admission-failure/failure-read/failure-read.ts b/mods/sec-default/hooks/admission-failure/failure-read/failure-read.ts new file mode 100644 index 0000000000..663d6b38be --- /dev/null +++ b/mods/sec-default/hooks/admission-failure/failure-read/failure-read.ts @@ -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 +} diff --git a/mods/sec-default/hooks/admission-failure/failure-read/index.ts b/mods/sec-default/hooks/admission-failure/failure-read/index.ts new file mode 100644 index 0000000000..b25eb388ff --- /dev/null +++ b/mods/sec-default/hooks/admission-failure/failure-read/index.ts @@ -0,0 +1,3 @@ +export type * from './failure-read.js' + +export * as default from '.' diff --git a/mods/sec-default/hooks/admission-failure/index.ts b/mods/sec-default/hooks/admission-failure/index.ts index d7ceff2555..99d86896e4 100644 --- a/mods/sec-default/hooks/admission-failure/index.ts +++ b/mods/sec-default/hooks/admission-failure/index.ts @@ -1,3 +1,4 @@ export * from './admission-failure.js' +export * from './failure-read' export * as default from '.' diff --git a/mods/sec-default/hooks/attempted/attempted.ts b/mods/sec-default/hooks/attempted/attempted.ts new file mode 100644 index 0000000000..affe8fd820 --- /dev/null +++ b/mods/sec-default/hooks/attempted/attempted.ts @@ -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 = (call: () => T | Promise) => + (async () => call())() diff --git a/mods/sec-default/hooks/attempted/index.ts b/mods/sec-default/hooks/attempted/index.ts new file mode 100644 index 0000000000..b12ecc884a --- /dev/null +++ b/mods/sec-default/hooks/attempted/index.ts @@ -0,0 +1,3 @@ +export * from './attempted.js' + +export * as default from '.' diff --git a/mods/sec-default/hooks/caught/admission-caught.ts b/mods/sec-default/hooks/caught/admission-caught.ts new file mode 100644 index 0000000000..9c109f5013 --- /dev/null +++ b/mods/sec-default/hooks/caught/admission-caught.ts @@ -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: E, + next: Types.CaughtNext & Types.Failed, + log: (line: string) => unknown, +) { + quietly(() => log(admissionFailure(e.name, next.error))) + + return next.called ? next(e) : { refuse: managedModsOnlyRefusal(e.name) } +} diff --git a/mods/sec-default/hooks/caught/check-caught.ts b/mods/sec-default/hooks/caught/check-caught.ts new file mode 100644 index 0000000000..6c35f6abba --- /dev/null +++ b/mods/sec-default/hooks/caught/check-caught.ts @@ -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, +) { + 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, + ) +} diff --git a/mods/sec-default/hooks/caught/index.ts b/mods/sec-default/hooks/caught/index.ts new file mode 100644 index 0000000000..679ead32cf --- /dev/null +++ b/mods/sec-default/hooks/caught/index.ts @@ -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 '.' diff --git a/mods/sec-default/hooks/caught/past-users-caught.ts b/mods/sec-default/hooks/caught/past-users-caught.ts new file mode 100644 index 0000000000..bd3426585b --- /dev/null +++ b/mods/sec-default/hooks/caught/past-users-caught.ts @@ -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: E, next: Types.CaughtNext) => + next.called ? next(e) : next.to(e, 'append') diff --git a/mods/sec-default/hooks/caught/provided-caught.ts b/mods/sec-default/hooks/caught/provided-caught.ts new file mode 100644 index 0000000000..3e5185eafc --- /dev/null +++ b/mods/sec-default/hooks/caught/provided-caught.ts @@ -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: E, + next: Types.CaughtNext, +) => (next.called ? next(e) : pastUsers(e, next)) diff --git a/mods/sec-default/hooks/caught/register-caught.ts b/mods/sec-default/hooks/caught/register-caught.ts new file mode 100644 index 0000000000..5c49b51880 --- /dev/null +++ b/mods/sec-default/hooks/caught/register-caught.ts @@ -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: E, + next: Types.CaughtNext & ToolRegistered.RegisterNext, + read: () => Promise, +) => + next.called + ? next(e) + : toolRegistered(e, next, () => + Policy.decidedByPolicy(attempted(read), Policy.hasMcpAllowlist), + ) diff --git a/mods/sec-default/hooks/caught/types/caught-next.ts b/mods/sec-default/hooks/caught/types/caught-next.ts new file mode 100644 index 0000000000..c70bc4b1b4 --- /dev/null +++ b/mods/sec-default/hooks/caught/types/caught-next.ts @@ -0,0 +1,21 @@ +import type { TargetTier } from 'claude-code' + +/** + * What a failure handler needs of its `next`: the call, the continuation + * past the user tier, and whether the failed hook had called either. + * + * The call replays the failed hook's last run, or runs the links beneath. + */ +export type CaughtNext = { + (e: E): Promise + + /** + * Continues the dispatch at `tier`, the links between skipped (Next's). + */ + readonly to: (e: E, tier: TargetTier) => Promise + + /** + * True when the failed hook had called `next` or `next.to` (Caught's). + */ + readonly called: boolean +} diff --git a/mods/sec-default/hooks/caught/types/check-next.ts b/mods/sec-default/hooks/caught/types/check-next.ts new file mode 100644 index 0000000000..45c354b612 --- /dev/null +++ b/mods/sec-default/hooks/caught/types/check-next.ts @@ -0,0 +1,14 @@ +import type { Args, EventResult, TraceEntry } from 'claude-code' + +/** + * What the `tool.check` failure handler needs of its `next`: the call, and + * the trace of the run it answers from. + */ +export type CheckNext = { + (e: Args<'tool.check'>): Promise> + + /** + * What settled beneath the hook on its latest call (Next's). + */ + readonly trace: readonly TraceEntry<'tool.check'>[] +} diff --git a/mods/sec-default/hooks/caught/types/failed.ts b/mods/sec-default/hooks/caught/types/failed.ts new file mode 100644 index 0000000000..1f37fce312 --- /dev/null +++ b/mods/sec-default/hooks/caught/types/failed.ts @@ -0,0 +1,8 @@ +import type AdmissionFailure from '../../admission-failure' + +/** + * What a failure handler's `next` carries of why its hook failed (Caught's). + */ +export type Failed = { + readonly error: AdmissionFailure.FailureRead +} diff --git a/mods/sec-default/hooks/caught/types/index.ts b/mods/sec-default/hooks/caught/types/index.ts new file mode 100644 index 0000000000..59113e9285 --- /dev/null +++ b/mods/sec-default/hooks/caught/types/index.ts @@ -0,0 +1,6 @@ +export type * from './caught-next.js' +export type * from './check-next.js' +export type * from './failed.js' +export type * from './judged.js' + +export * as default from '.' diff --git a/mods/sec-default/hooks/caught/types/judged.ts b/mods/sec-default/hooks/caught/types/judged.ts new file mode 100644 index 0000000000..4e685d5584 --- /dev/null +++ b/mods/sec-default/hooks/caught/types/judged.ts @@ -0,0 +1,7 @@ +/** + * What the `plugin.register` failure handler reads of the module being + * judged: its name. + */ +export type Judged = { + readonly name: string +} diff --git a/mods/sec-default/hooks/held-verdict/ceiling-notice.ts b/mods/sec-default/hooks/held-verdict/ceiling-notice.ts new file mode 100644 index 0000000000..bcd2299a61 --- /dev/null +++ b/mods/sec-default/hooks/held-verdict/ceiling-notice.ts @@ -0,0 +1,15 @@ +import type { Args } from 'claude-code' + +/** + * What a person reads, once for each plugin in a session, when a plugin they + * installed answered over the ceiling their organization set on a tool. + * + * No option lifts it: an administrator changes the ceiling where it is set. + * + * @param plugin the plugin's name, or its batch's, as the trace names it + * @param e the question: the tool the call named and its ceiling + * @returns the line + */ +export const ceilingNotice = (plugin: string, e: Args<'tool.check'>) => + `${plugin} tried to lift the limit your organization set on ${e.tool} ` + + `(${e.ceiling}); the limit holds over the plugins you install` diff --git a/mods/sec-default/hooks/held-verdict/index.ts b/mods/sec-default/hooks/held-verdict/index.ts index b6587e2458..7c3fd14ac9 100644 --- a/mods/sec-default/hooks/held-verdict/index.ts +++ b/mods/sec-default/hooks/held-verdict/index.ts @@ -1,5 +1,8 @@ export * from './caught-answer.js' +export * from './ceiling-notice.js' export * from './held-notice.js' +export * from './under-ceiling.js' +export * from './untold.js' export * from './verdicts' export * as default from '.' diff --git a/mods/sec-default/hooks/held-verdict/under-ceiling.ts b/mods/sec-default/hooks/held-verdict/under-ceiling.ts new file mode 100644 index 0000000000..5a058ec19e --- /dev/null +++ b/mods/sec-default/hooks/held-verdict/under-ceiling.ts @@ -0,0 +1,26 @@ +import type { EventResult } from 'claude-code' + +import Verdicts from './verdicts' + +/** + * What the `tool.check` hook's failure handler answers on a tool under an + * organization's ceiling: its verdict when within the ceiling, else that. + * + * No verdict stays none, so a run that rejected still fails the call. Where + * the organization set no ceiling the verdict passes as it is. + * + * @param caught the verdict the handler would return, if it has one + * @param ceiling the question's `ceiling`, which the engine pins + * @returns the verdict the handler returns + */ +export function underCeiling( + caught: EventResult<'tool.check'> | undefined, + ceiling: string | undefined, +) { + const isOver = + caught !== undefined && + ceiling !== undefined && + Verdicts.isOverCeiling(caught, ceiling) + + return isOver ? Verdicts.uncheckedCeiling(ceiling) : caught +} diff --git a/mods/sec-default/hooks/held-verdict/untold.ts b/mods/sec-default/hooks/held-verdict/untold.ts new file mode 100644 index 0000000000..9dd47163b8 --- /dev/null +++ b/mods/sec-default/hooks/held-verdict/untold.ts @@ -0,0 +1,10 @@ +/** + * The plugins among those named that a person has not been told about yet + * in this session, each once however many of its links were named. + * + * @param told the names already told + * @param names the names one run of the chain gave + * @returns the names to tell now, in the order given + */ +export const untold = (told: ReadonlySet, names: readonly string[]) => + names.filter((name, at) => !told.has(name) && names.indexOf(name) === at) diff --git a/mods/sec-default/hooks/held-verdict/verdicts/index.ts b/mods/sec-default/hooks/held-verdict/verdicts/index.ts index 648e4d6885..57777eefe4 100644 --- a/mods/sec-default/hooks/held-verdict/verdicts/index.ts +++ b/mods/sec-default/hooks/held-verdict/verdicts/index.ts @@ -1,7 +1,11 @@ +export * from './is-ceiling-held.js' export * from './is-rule-deny.js' +export * from './lifted-by-users.js' export * from './loosened-by-users.js' +export * from './loosened-links' export * from './ranking' export * from './types' +export * from './unchecked-ceiling.js' export * from './unchecked-deny.js' export * as default from '.' diff --git a/mods/sec-default/hooks/held-verdict/verdicts/is-ceiling-held.ts b/mods/sec-default/hooks/held-verdict/verdicts/is-ceiling-held.ts new file mode 100644 index 0000000000..af104b38a0 --- /dev/null +++ b/mods/sec-default/hooks/held-verdict/verdicts/is-ceiling-held.ts @@ -0,0 +1,21 @@ +import type { EventResult } from 'claude-code' + +import Ranking from './ranking' + +/** + * Whether the run past the user tier holds over the chain's answer for the + * organization's ceiling: the answer is over it, and that run is stricter. + * + * A run as permissive as the answer holds nothing, so the verdict returned + * in the answer's place is never the looser of the two. + * + * @param answer what the whole chain settled on + * @param held what the run past the user tier settled on + * @param ceiling the question's `ceiling`, which the engine pins + * @returns true when the run past the user tier is the answer + */ +export const isCeilingHeld = ( + answer: EventResult<'tool.check'>, + held: EventResult<'tool.check'>, + ceiling: string | undefined, +) => Ranking.isOverCeiling(answer, ceiling) && Ranking.isLooser(answer, held) diff --git a/mods/sec-default/hooks/held-verdict/verdicts/lifted-by-users.ts b/mods/sec-default/hooks/held-verdict/verdicts/lifted-by-users.ts new file mode 100644 index 0000000000..b626ca6035 --- /dev/null +++ b/mods/sec-default/hooks/held-verdict/verdicts/lifted-by-users.ts @@ -0,0 +1,27 @@ +import type { TraceEntry } from 'claude-code' + +import { loosenedLinks } from './loosened-links' +import Ranking from './ranking' + +/** + * The links of one run of `tool.check` that may hold a plugin a person + * installed and that answered over the organization's ceiling on the tool. + * + * A link that loosened the verdict it was handed and stayed within the + * ceiling is not among them. Read off `next.trace`, to name who is told. + * + * @param trace what settled beneath the hook on its latest `next` call + * @param ceiling the question's `ceiling`, which the engine pins + * @returns their names, nearest the caller first; none under no ceiling + */ +export const liftedByUsers = ( + trace: readonly TraceEntry<'tool.check'>[], + ceiling: string | undefined, +): readonly string[] => + loosenedLinks(trace) + .filter( + link => + link.returned !== undefined && + Ranking.isOverCeiling(link.returned, ceiling), + ) + .map(link => link.plugin) diff --git a/mods/sec-default/hooks/held-verdict/verdicts/loosened-by-users.ts b/mods/sec-default/hooks/held-verdict/verdicts/loosened-by-users.ts index 5b491e8ef7..84c473206a 100644 --- a/mods/sec-default/hooks/held-verdict/verdicts/loosened-by-users.ts +++ b/mods/sec-default/hooks/held-verdict/verdicts/loosened-by-users.ts @@ -1,6 +1,6 @@ import type { TraceEntry } from 'claude-code' -import Ranking from './ranking' +import { loosenedLinks } from './loosened-links' /** * The links of one run of `tool.check` that may hold a plugin a person @@ -15,11 +15,4 @@ import Ranking from './ranking' */ export const loosenedByUsers = ( trace: readonly TraceEntry<'tool.check'>[], -): readonly string[] => - trace - .filter( - (link, at) => - Ranking.TIERS_HOLDING_USERS.includes(link.tier) && - Ranking.isLooser(link.returned, Ranking.handedTo(trace, at)), - ) - .map(link => link.plugin) +): readonly string[] => loosenedLinks(trace).map(link => link.plugin) diff --git a/mods/sec-default/hooks/held-verdict/verdicts/loosened-links/index.ts b/mods/sec-default/hooks/held-verdict/verdicts/loosened-links/index.ts new file mode 100644 index 0000000000..d63ac72a67 --- /dev/null +++ b/mods/sec-default/hooks/held-verdict/verdicts/loosened-links/index.ts @@ -0,0 +1,3 @@ +export * from './loosened-links.js' + +export * as default from '.' diff --git a/mods/sec-default/hooks/held-verdict/verdicts/loosened-links/loosened-links.ts b/mods/sec-default/hooks/held-verdict/verdicts/loosened-links/loosened-links.ts new file mode 100644 index 0000000000..7193b0d4b0 --- /dev/null +++ b/mods/sec-default/hooks/held-verdict/verdicts/loosened-links/loosened-links.ts @@ -0,0 +1,19 @@ +import type { TraceEntry } from 'claude-code' + +import Ranking from '../ranking' + +/** + * The links of one run of `tool.check` that may hold a plugin a person + * installed and that answered more permissively than they were handed. + * + * Read off `next.trace`, whose `tier` the engine pins. + * + * @param trace what settled beneath the hook on its latest `next` call + * @returns the links, nearest the caller first; none when none loosened + */ +export const loosenedLinks = (trace: readonly TraceEntry<'tool.check'>[]) => + trace.filter( + (link, at) => + Ranking.TIERS_HOLDING_USERS.includes(link.tier) && + Ranking.isLooser(link.returned, Ranking.handedTo(trace, at)), + ) diff --git a/mods/sec-default/hooks/held-verdict/verdicts/ranking/ceiling-verdict/ceiling-verdict.ts b/mods/sec-default/hooks/held-verdict/verdicts/ranking/ceiling-verdict/ceiling-verdict.ts new file mode 100644 index 0000000000..792acdbc8a --- /dev/null +++ b/mods/sec-default/hooks/held-verdict/verdicts/ranking/ceiling-verdict/ceiling-verdict.ts @@ -0,0 +1,11 @@ +/** + * The verdict a ceiling holds a call at: the ceiling itself when it is one + * of the three verdicts, else a deny. + * + * So a ceiling this plugin does not know lifts nothing: it fails closed. + * + * @param ceiling the question's `ceiling`, as the engine names it + * @returns the most permissive verdict within the ceiling + */ +export const ceilingVerdict = (ceiling: string) => + (['allow', 'ask', 'deny'] as const).find(known => known === ceiling) ?? 'deny' diff --git a/mods/sec-default/hooks/held-verdict/verdicts/ranking/ceiling-verdict/index.ts b/mods/sec-default/hooks/held-verdict/verdicts/ranking/ceiling-verdict/index.ts new file mode 100644 index 0000000000..a44fffc69a --- /dev/null +++ b/mods/sec-default/hooks/held-verdict/verdicts/ranking/ceiling-verdict/index.ts @@ -0,0 +1,3 @@ +export * from './ceiling-verdict.js' + +export * as default from '.' diff --git a/mods/sec-default/hooks/held-verdict/verdicts/ranking/index.ts b/mods/sec-default/hooks/held-verdict/verdicts/ranking/index.ts index f065238b96..1be2c0324a 100644 --- a/mods/sec-default/hooks/held-verdict/verdicts/ranking/index.ts +++ b/mods/sec-default/hooks/held-verdict/verdicts/ranking/index.ts @@ -1,5 +1,7 @@ +export * from './ceiling-verdict' export * from './handed-to.js' export * from './is-looser.js' +export * from './is-over-ceiling.js' export * from './leniency' export * from './tiers-holding-users' diff --git a/mods/sec-default/hooks/held-verdict/verdicts/ranking/is-over-ceiling.ts b/mods/sec-default/hooks/held-verdict/verdicts/ranking/is-over-ceiling.ts new file mode 100644 index 0000000000..30989bab04 --- /dev/null +++ b/mods/sec-default/hooks/held-verdict/verdicts/ranking/is-over-ceiling.ts @@ -0,0 +1,19 @@ +import type { EventResult } from 'claude-code' + +import { ceilingVerdict } from './ceiling-verdict' +import { LENIENCY } from './leniency' + +/** + * Whether a `tool.check` verdict is more permissive than the ceiling the + * organization set on the tool: never where it set none. + * + * @param verdict what a run of the chain settled on + * @param ceiling the question's `ceiling`, which the engine pins + * @returns true when the verdict lets the call go further than the ceiling + */ +export const isOverCeiling = ( + verdict: EventResult<'tool.check'>, + ceiling: string | undefined, +) => + ceiling !== undefined && + LENIENCY[verdict.decision] > LENIENCY[ceilingVerdict(ceiling)] diff --git a/mods/sec-default/hooks/held-verdict/verdicts/unchecked-ceiling.ts b/mods/sec-default/hooks/held-verdict/verdicts/unchecked-ceiling.ts new file mode 100644 index 0000000000..9f08d6595f --- /dev/null +++ b/mods/sec-default/hooks/held-verdict/verdicts/unchecked-ceiling.ts @@ -0,0 +1,19 @@ +import type { EventResult } from 'claude-code' + +import Ranking from './ranking' + +/** + * What the failure handler answers in place of a verdict over the ceiling + * an organization set on a tool: the ceiling's own verdict. + * + * @param ceiling the question's `ceiling`, which the engine pins + * @returns the verdict + */ +export const uncheckedCeiling = ( + ceiling: string, +): EventResult<'tool.check'> => ({ + decision: Ranking.ceilingVerdict(ceiling), + reason: + 'the limit your organization set on this tool could not be checked ' + + 'for this call, so the call goes no further than the limit', +}) diff --git a/mods/sec-default/hooks/hooks.json b/mods/sec-default/hooks/hooks.json index 4a70fc2dcf..c0278024ab 100644 --- a/mods/sec-default/hooks/hooks.json +++ b/mods/sec-default/hooks/hooks.json @@ -1,4 +1,4 @@ { - "description": "Security default: from the outermost seat, continues past the user tier on the organization's classic hooks, prompt content, settings and subjects, refuses a user-tier tool.register under an MCP allowlist, restores the organization's tools in tool.list, holds a settings deny rule over a user-tier allow or ask on tool.check, and refuses a user-tier hooks module at plugin.register while managed settings set its allowManagedModsOnly option", + "description": "Security default: from the outermost seat, continues past the user tier on the organization's classic hooks, prompt content, settings and subjects, refuses a user-tier tool.register under an MCP allowlist, restores the organization's tools in tool.list, holds a 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", "modules": ["./register.ts"] } diff --git a/mods/sec-default/hooks/index.ts b/mods/sec-default/hooks/index.ts index bd91ec39d2..6d5f329535 100644 --- a/mods/sec-default/hooks/index.ts +++ b/mods/sec-default/hooks/index.ts @@ -1,9 +1,14 @@ export * from './admission-failure' +export * from './attempted' +export * from './caught' export * from './held-verdict' export * from './managed-mods-only-refusal' export * from './past-users' export * from './policy' +export * from './quietly' export * from './register.js' export * from './tool-register-refusal' +export * from './tool-registered' +export * from './tools-listed' export * as default from '.' diff --git a/mods/sec-default/hooks/past-users/user-reachable-tiers/user-reachable-tiers.ts b/mods/sec-default/hooks/past-users/user-reachable-tiers/user-reachable-tiers.ts index 10c3da2ec8..feaeca5c35 100644 --- a/mods/sec-default/hooks/past-users/user-reachable-tiers/user-reachable-tiers.ts +++ b/mods/sec-default/hooks/past-users/user-reachable-tiers/user-reachable-tiers.ts @@ -1,8 +1,8 @@ /** - * The provider tiers whose subject the user tier may still rewrite: a - * person's plugin, a bundled one, the engine. + * The tiers whose subject, or whose own call, the user tier may still hear + * and rewrite: a person's plugin, a bundled one, the engine. * - * Any other provider, or none, is the organization's (fail closed). + * Any other provider or caller, or none, is the organization's (fail closed). */ export const USER_REACHABLE_TIERS: readonly unknown[] = Object.freeze([ 'user', diff --git a/mods/sec-default/hooks/policy/create-policy-memo/create-policy-memo.ts b/mods/sec-default/hooks/policy/create-policy-memo/create-policy-memo.ts index f5c0529ea4..3b0322181d 100644 --- a/mods/sec-default/hooks/policy/create-policy-memo/create-policy-memo.ts +++ b/mods/sec-default/hooks/policy/create-policy-memo/create-policy-memo.ts @@ -1,5 +1,7 @@ import type { Settings } from 'claude-code' +import { attempted } from '../../attempted' + /** * A reader of managed policy that serves one read to every caller inside a * window after it, rejections included (a failed read still fails closed). @@ -20,7 +22,7 @@ export function createPolicyMemo( if (isStale) { heldAt = now() - held = read() + held = attempted(read) } return held ?? read() diff --git a/mods/sec-default/hooks/quietly/index.ts b/mods/sec-default/hooks/quietly/index.ts new file mode 100644 index 0000000000..2be2a946fc --- /dev/null +++ b/mods/sec-default/hooks/quietly/index.ts @@ -0,0 +1,3 @@ +export * from './quietly.js' + +export * as default from '.' diff --git a/mods/sec-default/hooks/quietly/quietly.ts b/mods/sec-default/hooks/quietly/quietly.ts new file mode 100644 index 0000000000..c6e0e49ea2 --- /dev/null +++ b/mods/sec-default/hooks/quietly/quietly.ts @@ -0,0 +1,11 @@ +import { attempted } from '../attempted' + +/** + * Makes a call on `$` whose outcome nothing depends on (a line logged): a + * throw or a rejection ends there. + * + * @param call the call, made inside the hook that asked for it + * @returns nothing + */ +export const quietly = (call: () => unknown) => + void attempted(call).catch(() => undefined) diff --git a/mods/sec-default/hooks/register.ts b/mods/sec-default/hooks/register.ts index 8b8c8000cf..eff1c1abaa 100644 --- a/mods/sec-default/hooks/register.ts +++ b/mods/sec-default/hooks/register.ts @@ -1,11 +1,13 @@ import type { On } from 'claude-code' -import { admissionFailure } from './admission-failure' +import Caught from './caught' import HeldVerdict from './held-verdict' import { managedModsOnlyRefusal } from './managed-mods-only-refusal' -import { pastUsers } from './past-users' +import PastUsers from './past-users' import Policy from './policy' -import { TOOL_REGISTER_REFUSAL } from './tool-register-refusal' +import { quietly } from './quietly' +import { toolRegistered } from './tool-registered' +import { toolsListed } from './tools-listed' /** * The built-in's hooks, seated outermost: each keeps one control an @@ -20,8 +22,11 @@ import { TOOL_REGISTER_REFUSAL } from './tool-register-refusal' export function register(on: On) { const readPolicy = Policy.createPolicyMemo(Policy.POLICY_MEMO_MS) const told = new Set() + const toldOfCeiling = new Set() - on('classic.*', ($, e, next) => next.to(e, 'append')) + on('classic.*', ($, e, next) => next.to(e, 'append')).catch(($, e, next) => + Caught.pastUsersCaught(e, next), + ) on('prompt.section', ($, e, next) => next.to(e, 'append')) on('prompt.context', ($, e, next) => next.to(e, 'append')) @@ -29,46 +34,53 @@ export function register(on: On) { on('skill.prompt', ($, e, next) => next.to(e, 'append')) on('attribution.text', ($, e, next) => next.to(e, 'append')) - on('settings.read', ($, e, next) => next.to(e, 'append')) + on('settings.read', ($, e, next) => next.to(e, 'append')).catch( + ($, e, next) => Caught.pastUsersCaught(e, next), + ) - on('tool.describe', ($, e, next) => pastUsers(e, next)) - on('command.describe', ($, e, next) => pastUsers(e, next)) - on('agent.offer', ($, e, next) => pastUsers(e, next)) - on('agent.spawn', ($, e, next) => pastUsers(e, next)) + on('tool.describe', ($, e, next) => PastUsers.pastUsers(e, next)).catch( + ($, e, next) => Caught.providedCaught(e, next), + ) - on('tool.register', async ($, e, next) => { - const isOrgs = - next.origin.tier === 'prepend' || next.origin.tier === 'append' + on('command.describe', ($, e, next) => PastUsers.pastUsers(e, next)).catch( + ($, e, next) => Caught.providedCaught(e, next), + ) - if (isOrgs) { - return next.to(e, 'append') - } + on('agent.offer', ($, e, next) => PastUsers.pastUsers(e, next)).catch( + ($, e, next) => Caught.providedCaught(e, next), + ) - const isRefused = - next.origin.tier === 'user' && - (await Policy.decidedByPolicy( + on('agent.spawn', ($, e, next) => PastUsers.pastUsers(e, next)).catch( + ($, e, next) => Caught.providedCaught(e, next), + ) + + on('tool.register', ($, e, next) => + toolRegistered(e, next, () => + Policy.decidedByPolicy( readPolicy(() => $.settings.read(Policy.SOURCE)), Policy.hasMcpAllowlist, - )) - - return isRefused ? { deny: TOOL_REGISTER_REFUSAL } : next(e) - }) + ), + ), + ).catch(($, e, next) => + Caught.registerCaught(e, next, () => $.settings.read(Policy.SOURCE)), + ) on('tool.list', async ($, e, next) => - Policy.managedToolsRestored( + toolsListed( await readPolicy(() => $.settings.read(Policy.SOURCE)).catch( () => undefined, ), - await next.to(e, 'append'), - await next(e), + e, + next, ), - ) + ).catch(($, e, next) => Caught.pastUsersCaught(e, next)) on('tool.check', async ($, e, next) => { const answer = await next(e) const mods = HeldVerdict.loosenedByUsers(next.trace) + const lifters = HeldVerdict.liftedByUsers(next.trace, e.ceiling) - const shouldRecheck = + const doRulesHold = answer.decision !== 'deny' && mods.length > 0 && (await Policy.decidedByPolicy( @@ -76,40 +88,46 @@ export function register(on: On) { Policy.denyRulesHold, )) - if (!shouldRecheck) { + if (!doRulesHold && !HeldVerdict.isOverCeiling(answer, e.ceiling)) { return answer } const held = await next.to(e, 'append') - if (!HeldVerdict.isRuleDeny(held)) { + if (doRulesHold && HeldVerdict.isRuleDeny(held)) { + for (const mod of HeldVerdict.untold(told, mods)) { + told.add(mod) + quietly(() => $.ui.log(HeldVerdict.heldNotice(mod, e.tool, held.rule))) + } + + return held + } + + if (!HeldVerdict.isCeilingHeld(answer, held, e.ceiling)) { return answer } - for (const mod of mods.filter(name => !told.has(name))) { - told.add(mod) - $.ui.log(HeldVerdict.heldNotice(mod, e.tool, held.rule)) + for (const mod of HeldVerdict.untold(toldOfCeiling, lifters)) { + toldOfCeiling.add(mod) + quietly(() => $.ui.log(HeldVerdict.ceilingNotice(mod, e))) } return held - }).catch(async ($, e, next) => { - const last = await next(e).catch(() => undefined) - - const shouldVouch = await Policy.decidedByPolicy( - readPolicy(() => $.settings.read(Policy.SOURCE)), - Policy.denyRulesHold, - ) + }).catch(($, e, next) => + Caught.checkCaught(e, next, () => $.settings.read(Policy.SOURCE)), + ) - return shouldVouch ? HeldVerdict.caughtAnswer(last, next.trace) : last - }) + on('ui.log', ($, e, next) => + PastUsers.USER_REACHABLE_TIERS.includes(next.origin.tier) + ? next(e) + : next.to(e, 'append'), + ).catch(($, e, next) => Caught.pastUsersCaught(e, next)) on('plugin.register', { tier: 'user' }, async ($, e, next) => Policy.isManagedModsOnly(await $.settings.read(Policy.SOURCE)) ? { refuse: managedModsOnlyRefusal(e.name) } : next(e), - ).catch(($, e, next) => { - $.ui.log(admissionFailure(e.name, next.error), { to: 'debug' }) - - return next.called ? next(e) : { refuse: managedModsOnlyRefusal(e.name) } - }) + ).catch(($, e, next) => + Caught.admissionCaught(e, next, line => $.ui.log(line, { to: 'debug' })), + ) } diff --git a/mods/sec-default/hooks/tool-registered/index.ts b/mods/sec-default/hooks/tool-registered/index.ts new file mode 100644 index 0000000000..485f32b892 --- /dev/null +++ b/mods/sec-default/hooks/tool-registered/index.ts @@ -0,0 +1,4 @@ +export * from './tool-registered.js' +export * from './types' + +export * as default from '.' diff --git a/mods/sec-default/hooks/tool-registered/tool-registered.ts b/mods/sec-default/hooks/tool-registered/tool-registered.ts new file mode 100644 index 0000000000..242fbe2a45 --- /dev/null +++ b/mods/sec-default/hooks/tool-registered/tool-registered.ts @@ -0,0 +1,31 @@ +import { TOOL_REGISTER_REFUSAL } from '../tool-register-refusal' +import type Types from './types' + +/** + * What the `tool.register` hook decides, by the caller's tier and the + * organization's MCP allowlist. + * + * An organization's registration continues past the user tier; a person's + * plugin is refused by name under an allowlist; every other passes. + * + * @param e the tool being added + * @param next the hook's own continuation, with the caller's origin + * @param isAllowlisted whether managed settings hold an MCP allowlist, + * asked only for a caller in the user tier; it fails closed + * @returns the registration's answer + */ +export async function toolRegistered( + e: E, + next: Types.RegisterNext, + isAllowlisted: () => Promise, +) { + const isOrgs = next.origin.tier === 'prepend' || next.origin.tier === 'append' + + if (isOrgs) { + return next.to(e, 'append') + } + + const isRefused = next.origin.tier === 'user' && (await isAllowlisted()) + + return isRefused ? { deny: TOOL_REGISTER_REFUSAL } : next(e) +} diff --git a/mods/sec-default/hooks/tool-registered/types/index.ts b/mods/sec-default/hooks/tool-registered/types/index.ts new file mode 100644 index 0000000000..419a56237e --- /dev/null +++ b/mods/sec-default/hooks/tool-registered/types/index.ts @@ -0,0 +1,3 @@ +export type * from './register-next.js' + +export * as default from '.' diff --git a/mods/sec-default/hooks/tool-registered/types/register-next.ts b/mods/sec-default/hooks/tool-registered/types/register-next.ts new file mode 100644 index 0000000000..b371091a98 --- /dev/null +++ b/mods/sec-default/hooks/tool-registered/types/register-next.ts @@ -0,0 +1,19 @@ +import type { TargetTier } from 'claude-code' + +/** + * What the `tool.register` decision needs of `next`: the call, the + * continuation past the user tier, and the caller's pinned tier. + */ +export type RegisterNext = { + (e: E): Promise + + /** + * Continues the dispatch at `tier`, the links between skipped (Next's). + */ + readonly to: (e: E, tier: TargetTier) => Promise + + /** + * Who raised the dispatch (Next's), read for its tier. + */ + readonly origin: { readonly tier: unknown } +} diff --git a/mods/sec-default/hooks/tools-listed/index.ts b/mods/sec-default/hooks/tools-listed/index.ts new file mode 100644 index 0000000000..fc865c76b5 --- /dev/null +++ b/mods/sec-default/hooks/tools-listed/index.ts @@ -0,0 +1,3 @@ +export * from './tools-listed.js' + +export * as default from '.' diff --git a/mods/sec-default/hooks/tools-listed/tools-listed.ts b/mods/sec-default/hooks/tools-listed/tools-listed.ts new file mode 100644 index 0000000000..7aed09c990 --- /dev/null +++ b/mods/sec-default/hooks/tools-listed/tools-listed.ts @@ -0,0 +1,25 @@ +import type { Settings, ToolInfo, ValueOrDeny } from 'claude-code' + +import type { ProvidedNext } from '../past-users' +import Policy from '../policy' + +/** + * What the `tool.list` hook answers: the listing through every tier and the + * one past the user tier, merged by managed policy. + * + * Both runs start together, the one past the user tier last. + * + * @param policy the managed settings, or undefined when the read failed + * @param e the event's input + * @param next the hook's own continuation + * @returns the merged answer + */ +export async function toolsListed( + policy: Settings | undefined, + e: E, + next: ProvidedNext>, +) { + const [seen, real] = await Promise.all([next(e), next.to(e, 'append')]) + + return Policy.managedToolsRestored(policy, real, seen) +} diff --git a/mods/sec-default/tests/caught.test.ts b/mods/sec-default/tests/caught.test.ts new file mode 100644 index 0000000000..018136dfe2 --- /dev/null +++ b/mods/sec-default/tests/caught.test.ts @@ -0,0 +1,351 @@ +import { describe, expect, test, tier } from 'claude-code/testing' + +import Hooks from '../hooks' +import Fixtures from './fixtures' + +tier('prepend') + +describe('caught', () => { + test('the check handler refuses a verdict of theirs unchecked', async () => { + const handler = Fixtures.handlersRegistered()('tool.check') + + const loosened = Fixtures.unchecked([ + Fixtures.linkOf('easy', 'user', Fixtures.ALLOWED), + Fixtures.linkOf('engine', 'core', Fixtures.ASKED), + ]) + + expect([ + await handler(Fixtures.UNREADABLE, Fixtures.CHECKED, loosened), + await handler(Fixtures.THROWING, Fixtures.CHECKED, loosened), + await handler( + Fixtures.answering(Fixtures.MANAGED_POLICY), + Fixtures.CHECKED, + loosened, + ), + ]).toEqual([ + Hooks.UNCHECKED_DENY, + Hooks.UNCHECKED_DENY, + Hooks.UNCHECKED_DENY, + ]) + }) + + test('under a ceiling, where plugins may override, it asks', async () => { + const handler = Fixtures.handlersRegistered()('tool.check') + + const loosened = Fixtures.unchecked([ + Fixtures.linkOf('easy', 'user', Fixtures.ALLOWED), + Fixtures.linkOf('engine', 'core', Fixtures.CAPPED_ASK), + ]) + + expect([ + await handler( + Fixtures.answering(Fixtures.overridePolicyOf(true)), + Fixtures.CAPPED, + loosened, + ), + await handler(Fixtures.UNREADABLE, Fixtures.CAPPED, loosened), + ]).toEqual([Hooks.uncheckedCeiling('ask'), Hooks.UNCHECKED_DENY]) + }) + + test('a verdict none of theirs loosened stands with it', async () => { + expect( + await Fixtures.handlersRegistered()('tool.check')( + Fixtures.UNREADABLE, + Fixtures.CHECKED, + Fixtures.unchecked([ + Fixtures.linkOf('listening', 'user', Fixtures.ASKED), + Fixtures.linkOf('engine', 'core', Fixtures.ASKED), + ]), + ), + ).toEqual(Fixtures.ASKED) + }) + + test('the register handler refuses a plugin of theirs', async () => { + const handler = Fixtures.handlersRegistered()('tool.register') + const next = Fixtures.uncalled('beneath', 'past') + + expect([ + await handler(Fixtures.UNREADABLE, Fixtures.JUDGED, next), + await handler(Fixtures.THROWING, Fixtures.JUDGED, next), + await handler( + Fixtures.answering(Fixtures.ALLOWLIST), + Fixtures.JUDGED, + next, + ), + ]).toEqual([ + { deny: Hooks.TOOL_REGISTER_REFUSAL }, + { deny: Hooks.TOOL_REGISTER_REFUSAL }, + { deny: Hooks.TOOL_REGISTER_REFUSAL }, + ]) + }) + + test('it reads policy itself, each time it is asked', async () => { + const handler = Fixtures.handlersRegistered()('tool.register') + const next = Fixtures.uncalled('beneath', 'past') + + expect([ + await handler( + Fixtures.answering(Fixtures.ALLOWLIST), + Fixtures.JUDGED, + next, + ), + await handler( + Fixtures.answering(Fixtures.NO_ALLOWLIST), + Fixtures.JUDGED, + next, + ), + ]).toEqual([{ deny: Hooks.TOOL_REGISTER_REFUSAL }, 'beneath']) + }) + + test('it decides for every other caller as the hook does', async () => { + const handler = Fixtures.handlersRegistered()('tool.register') + + expect([ + await handler( + Fixtures.UNREADABLE, + Fixtures.JUDGED, + Fixtures.uncalled('beneath', 'past', 'prepend'), + ), + await handler( + Fixtures.UNREADABLE, + Fixtures.JUDGED, + Fixtures.uncalled('beneath', 'past', 'builtin'), + ), + await handler( + Fixtures.answering(Fixtures.NO_ALLOWLIST), + Fixtures.JUDGED, + Fixtures.uncalled('beneath', 'past'), + ), + ]).toEqual(['past', 'beneath', 'beneath']) + }) + + test('the list and log handlers answer past theirs', async () => { + const registered = Fixtures.handlersRegistered() + const next = Fixtures.uncalled('beneath', 'past') + + expect([ + await registered('tool.list')(Fixtures.UNREADABLE, Fixtures.JUDGED, next), + await registered('tool.list')(Fixtures.THROWING, Fixtures.JUDGED, next), + await registered('ui.log')(Fixtures.UNREADABLE, Fixtures.JUDGED, next), + await registered('ui.log')(Fixtures.THROWING, Fixtures.JUDGED, next), + ]).toEqual(['past', 'past', 'past', 'past']) + }) + + test('the admission handler refuses, log or no log', async () => { + const handler = Fixtures.handlersRegistered()('plugin.register') + const next = Fixtures.uncalled('beneath', 'past') + const lines: string[] = [] + + expect([ + await handler(Fixtures.UNREADABLE, Fixtures.JUDGED, next), + await handler(Fixtures.THROWING, Fixtures.JUDGED, next), + await handler( + Fixtures.answering(Fixtures.MANAGED_POLICY, line => { + lines.push(line) + }), + Fixtures.JUDGED, + next, + ), + ]).toEqual([ + { refuse: Hooks.managedModsOnlyRefusal('mine') }, + { refuse: Hooks.managedModsOnlyRefusal('mine') }, + { refuse: Hooks.managedModsOnlyRefusal('mine') }, + ]) + + expect(lines).toEqual([Hooks.admissionFailure('mine', { kind: 'throw' })]) + }) + + test('a hook that had called next gets that call back', async () => { + const registered = Fixtures.handlersRegistered() + const next = Fixtures.replaying('last') + + expect([ + await registered('tool.register')( + Fixtures.UNREADABLE, + Fixtures.JUDGED, + next, + ), + await registered('tool.list')(Fixtures.UNREADABLE, Fixtures.JUDGED, next), + await registered('ui.log')(Fixtures.UNREADABLE, Fixtures.JUDGED, next), + await registered('plugin.register')( + Fixtures.UNREADABLE, + Fixtures.JUDGED, + next, + ), + ]).toEqual(['last', 'last', 'last', 'last']) + }) + + test('the classic and settings handlers answer past theirs', async () => { + const registered = Fixtures.handlersRegistered() + const next = Fixtures.uncalled('beneath', 'past') + + expect( + await Promise.all( + Fixtures.PASS_OVER_EVENTS.flatMap(event => [ + registered(event)(Fixtures.UNREADABLE, Fixtures.JUDGED, next), + registered(event)(Fixtures.THROWING, Fixtures.JUDGED, next), + ]), + ), + ).toEqual(['past', 'past', 'past', 'past']) + }) + + test('the subject handlers keep what is theirs from the rest', async () => { + const registered = Fixtures.handlersRegistered() + const next = Fixtures.uncalled('beneath', 'past') + + expect( + await Promise.all( + Fixtures.SUBJECT_EVENTS.flatMap(event => [ + registered(event)(Fixtures.UNREADABLE, Fixtures.ORGS_SUBJECT, next), + registered(event)(Fixtures.THROWING, Fixtures.THEIRS_SUBJECT, next), + ]), + ), + ).toEqual([ + 'past', + 'beneath', + 'past', + 'beneath', + 'past', + 'beneath', + 'past', + 'beneath', + ]) + }) + + test('they read the provider as their hooks do, tier by tier', async () => { + const registered = Fixtures.handlersRegistered() + const next = Fixtures.uncalled('beneath', 'past') + + const providers = [ + ...Fixtures.ORG_PROVIDERS, + ...Fixtures.ODD_PROVIDERS, + ...Fixtures.USER_REACHABLE_PROVIDERS, + ] + + const answers = [ + ...Fixtures.ORG_PROVIDERS.map(() => 'past'), + ...Fixtures.ODD_PROVIDERS.map(() => 'past'), + ...Fixtures.USER_REACHABLE_PROVIDERS.map(() => 'beneath'), + ] + + expect( + await Promise.all( + Fixtures.SUBJECT_EVENTS.flatMap(event => + providers.map(provider => + registered(event)(Fixtures.UNREADABLE, { provider }, next), + ), + ), + ), + ).toEqual(Fixtures.SUBJECT_EVENTS.flatMap(() => answers)) + + expect( + await Promise.all( + Fixtures.SUBJECT_EVENTS.flatMap(event => + providers.map(provider => + registered(event, 'hook')(Fixtures.UNREADABLE, { provider }, next), + ), + ), + ), + 'each hook, asked the same', + ).toEqual(Fixtures.SUBJECT_EVENTS.flatMap(() => answers)) + }) + + test('the classic and settings hooks pass over theirs', async () => { + const registered = Fixtures.handlersRegistered() + const next = Fixtures.uncalled('beneath', 'past') + + expect( + await Promise.all( + Fixtures.PASS_OVER_EVENTS.map(event => + registered(event, 'hook')(Fixtures.UNREADABLE, Fixtures.JUDGED, next), + ), + ), + ).toEqual(['past', 'past']) + }) + + test('each of the six that had called next gets that call back', async () => { + const registered = Fixtures.handlersRegistered() + const next = Fixtures.replaying('last') + + expect( + await Promise.all( + [...Fixtures.PASS_OVER_EVENTS, ...Fixtures.SUBJECT_EVENTS].map(event => + registered(event)(Fixtures.UNREADABLE, Fixtures.ORGS_SUBJECT, next), + ), + ), + ).toEqual(['last', 'last', 'last', 'last', 'last', 'last']) + }) + + test('a line the check hook cannot log changes no answer', async () => { + const next = Fixtures.rechecked( + [ + Fixtures.linkOf('easy', 'user', Fixtures.ALLOWED), + Fixtures.linkOf('engine', 'core', Fixtures.CAPPED_ASK), + ], + Fixtures.CAPPED_ASK, + ) + + expect([ + await Fixtures.handlersRegistered()('tool.check', 'hook')( + Fixtures.THROWING, + Fixtures.CAPPED, + next, + ), + await Fixtures.handlersRegistered()('tool.check', 'hook')( + Fixtures.UNREADABLE, + Fixtures.CAPPED, + next, + ), + ]).toEqual([Fixtures.CAPPED_ASK, Fixtures.CAPPED_ASK]) + }) + + test('nor does the line of a deny rule that held', async () => { + const next = Fixtures.rechecked( + [ + Fixtures.linkOf('easy', 'user', Fixtures.ALLOWED), + Fixtures.linkOf('engine', 'core', Fixtures.RULE_DENY), + ], + Fixtures.RULE_DENY, + ) + + expect([ + await Fixtures.handlersRegistered()('tool.check', 'hook')( + Fixtures.THROWING, + Fixtures.CHECKED, + next, + ), + await Fixtures.handlersRegistered()('tool.check', 'hook')( + Fixtures.UNREADABLE, + Fixtures.CHECKED, + next, + ), + ]).toEqual([Fixtures.RULE_DENY, Fixtures.RULE_DENY]) + }) + + test('the list hook starts its two runs together', async () => { + const order: string[] = [] + + const listed = Hooks.toolsListed( + Fixtures.ALLOWLIST, + Fixtures.JUDGED, + Fixtures.started(text => { + order.push(text) + }), + ) + + expect(order, 'both are started before either settles').toEqual([ + 'chain', + 'past append', + ]) + + expect((await listed).value).toEqual([...Fixtures.TOOLS]) + }) + + test('a call that throws where it is made is a rejection', async () => { + await expect( + Hooks.attempted(Fixtures.THROWING.settings.read), + ).rejects.toThrow('settings unreadable') + + expect(Hooks.quietly(Fixtures.THROWING.ui.log)).toBe(undefined) + }) +}) diff --git a/mods/sec-default/tests/fixtures/caught/answering.ts b/mods/sec-default/tests/fixtures/caught/answering.ts new file mode 100644 index 0000000000..9b70a11a13 --- /dev/null +++ b/mods/sec-default/tests/fixtures/caught/answering.ts @@ -0,0 +1,23 @@ +import type { Settings } from 'claude-code' + +/** + * A `$` whose calls are answered from the bottom: the policy given is read, + * and each line logged is handed on. + * + * @param policy the managed settings in force + * @param told what each logged line is handed to + * @returns the stand-in + */ +export const answering = ( + policy: Settings, + told: (line: string) => void = () => undefined, +) => ({ + settings: { read: () => Promise.resolve(policy) }, + ui: { + log(line: string) { + told(line) + + return Promise.resolve() + }, + }, +}) diff --git a/mods/sec-default/tests/fixtures/caught/handlers-registered.ts b/mods/sec-default/tests/fixtures/caught/handlers-registered.ts new file mode 100644 index 0000000000..f574c9d8c0 --- /dev/null +++ b/mods/sec-default/tests/fixtures/caught/handlers-registered.ts @@ -0,0 +1,30 @@ +import Hooks from '../../../hooks' + +/** + * What the plugin registers, read off its own `register`: each event's + * hook and its `.catch`, as the engine would call them. + * + * @returns `(event, part) => function`; it throws where there is none + */ +export function handlersRegistered() { + const registered = new Map() + + Hooks.register((event: string, ...rest: unknown[]) => { + registered.set(`${event} hook`, rest.at(-1)) + + return { + catch: (handler: unknown) => + void registered.set(`${event} catch`, handler), + } + }) + + return (event: string, part: 'hook' | 'catch' = 'catch') => { + const handler = registered.get(`${event} ${part}`) + + if (typeof handler !== 'function') { + throw new Error(`${event} carries no ${part}`) + } + + return handler + } +} diff --git a/mods/sec-default/tests/fixtures/caught/index.ts b/mods/sec-default/tests/fixtures/caught/index.ts new file mode 100644 index 0000000000..8dbc4131ea --- /dev/null +++ b/mods/sec-default/tests/fixtures/caught/index.ts @@ -0,0 +1,16 @@ +export * from './answering.js' +export * from './handlers-registered.js' +export * from './judged.js' +export * from './orgs-subject.js' +export * from './pass-over-events.js' +export * from './rechecked.js' +export * from './replaying.js' +export * from './started.js' +export * from './subject-events.js' +export * from './theirs-subject.js' +export * from './throwing.js' +export * from './uncalled.js' +export * from './unchecked.js' +export * from './unreadable.js' + +export * as default from '.' diff --git a/mods/sec-default/tests/fixtures/caught/judged.ts b/mods/sec-default/tests/fixtures/caught/judged.ts new file mode 100644 index 0000000000..47024b9335 --- /dev/null +++ b/mods/sec-default/tests/fixtures/caught/judged.ts @@ -0,0 +1,4 @@ +/** + * A hooks module a person installed, as `plugin.register` judges it. + */ +export const JUDGED = Object.freeze({ name: 'mine' }) diff --git a/mods/sec-default/tests/fixtures/caught/orgs-subject.ts b/mods/sec-default/tests/fixtures/caught/orgs-subject.ts new file mode 100644 index 0000000000..76dc7fcd9a --- /dev/null +++ b/mods/sec-default/tests/fixtures/caught/orgs-subject.ts @@ -0,0 +1,7 @@ +/** + * A subject the organization provides (a policy-installed plugin's tool, + * command or agent), as the events that carry a provider name it. + */ +export const ORGS_SUBJECT = Object.freeze({ + provider: Object.freeze({ plugin: 'suite@corp-market', tier: 'prepend' }), +}) diff --git a/mods/sec-default/tests/fixtures/caught/pass-over-events.ts b/mods/sec-default/tests/fixtures/caught/pass-over-events.ts new file mode 100644 index 0000000000..ac132f94ae --- /dev/null +++ b/mods/sec-default/tests/fixtures/caught/pass-over-events.ts @@ -0,0 +1,7 @@ +/** + * The events whose hook continues past the user tier whoever asks. + */ +export const PASS_OVER_EVENTS = Object.freeze([ + 'classic.*', + 'settings.read', +] as const) diff --git a/mods/sec-default/tests/fixtures/caught/rechecked.ts b/mods/sec-default/tests/fixtures/caught/rechecked.ts new file mode 100644 index 0000000000..2931d9e52b --- /dev/null +++ b/mods/sec-default/tests/fixtures/caught/rechecked.ts @@ -0,0 +1,19 @@ +import type { EventResult, TargetTier, TraceEntry } from 'claude-code' + +import { unchecked } from './unchecked.js' + +/** + * The `tool.check` hook's `next`: the call settles as the trace lists, and + * `to` answers the run past the user tier. + * + * @param trace the run beneath, nearest the caller first + * @param past what the run past the user tier settles to + * @returns the stand-in + */ +export const rechecked = ( + trace: readonly TraceEntry<'tool.check'>[], + past: EventResult<'tool.check'>, +) => + Object.assign(unchecked(trace), { + to: (_e: unknown, _tier: TargetTier) => Promise.resolve(past), + }) diff --git a/mods/sec-default/tests/fixtures/caught/replaying.ts b/mods/sec-default/tests/fixtures/caught/replaying.ts new file mode 100644 index 0000000000..3e173cf500 --- /dev/null +++ b/mods/sec-default/tests/fixtures/caught/replaying.ts @@ -0,0 +1,18 @@ +import type { TargetTier } from 'claude-code' + +/** + * A failure handler's `next` for a hook that had called it: the call + * replays what that last call settled to. + * + * Its `to` answers `to`, to tell the two apart. + * + * @param last what the failed hook's last call settled to + * @returns the stand-in + */ +export const replaying = (last: string) => + Object.assign((_e: unknown) => Promise.resolve(last), { + to: (_e: unknown, _tier: TargetTier) => Promise.resolve('to'), + called: true, + error: { kind: 'throw' }, + origin: { tier: 'user' }, + }) diff --git a/mods/sec-default/tests/fixtures/caught/started.ts b/mods/sec-default/tests/fixtures/caught/started.ts new file mode 100644 index 0000000000..b5c67b856a --- /dev/null +++ b/mods/sec-default/tests/fixtures/caught/started.ts @@ -0,0 +1,26 @@ +import type { TargetTier, ToolInfo, ValueOrDeny } from 'claude-code' + +import { TOOLS } from '../tools.js' + +/** + * A `tool.list` hook's `next` that keeps the order its two runs were + * started in, each answering the session's tools. + * + * @param order filled as the runs start: `chain`, then `past ` + * @returns the stand-in + */ +export const started = (order: (text: string) => void) => + Object.assign( + (_e: unknown): Promise> => { + order('chain') + + return Promise.resolve({ value: [...TOOLS] }) + }, + { + to: (_e: unknown, tier: TargetTier): Promise> => { + order(`past ${tier}`) + + return Promise.resolve({ value: [...TOOLS] }) + }, + }, + ) diff --git a/mods/sec-default/tests/fixtures/caught/subject-events.ts b/mods/sec-default/tests/fixtures/caught/subject-events.ts new file mode 100644 index 0000000000..33d1b83611 --- /dev/null +++ b/mods/sec-default/tests/fixtures/caught/subject-events.ts @@ -0,0 +1,10 @@ +/** + * The events whose subject names who provides it: the hook on each lets an + * organization's subject continue past the user tier. + */ +export const SUBJECT_EVENTS = Object.freeze([ + 'tool.describe', + 'command.describe', + 'agent.offer', + 'agent.spawn', +] as const) diff --git a/mods/sec-default/tests/fixtures/caught/theirs-subject.ts b/mods/sec-default/tests/fixtures/caught/theirs-subject.ts new file mode 100644 index 0000000000..d61cc4afdf --- /dev/null +++ b/mods/sec-default/tests/fixtures/caught/theirs-subject.ts @@ -0,0 +1,7 @@ +/** + * A subject a plugin the person installed provides, as the events that + * carry a provider name it. + */ +export const THEIRS_SUBJECT = Object.freeze({ + provider: Object.freeze({ plugin: 'mine@market', tier: 'user' }), +}) diff --git a/mods/sec-default/tests/fixtures/caught/throwing.ts b/mods/sec-default/tests/fixtures/caught/throwing.ts new file mode 100644 index 0000000000..2b4d403bac --- /dev/null +++ b/mods/sec-default/tests/fixtures/caught/throwing.ts @@ -0,0 +1,15 @@ +/** + * A `$` whose every call throws where it is made. + */ +export const THROWING = Object.freeze({ + settings: { + read() { + throw new Error('settings unreadable') + }, + }, + ui: { + log() { + throw new Error('log unwritable') + }, + }, +}) diff --git a/mods/sec-default/tests/fixtures/caught/uncalled.ts b/mods/sec-default/tests/fixtures/caught/uncalled.ts new file mode 100644 index 0000000000..249bf80ed5 --- /dev/null +++ b/mods/sec-default/tests/fixtures/caught/uncalled.ts @@ -0,0 +1,26 @@ +import type { TargetTier } from 'claude-code' + +/** + * A failure handler's `next` for a hook that had not called it: the call + * runs the links beneath, `to` at `append` the run past the user tier. + * + * Continued at any other tier it rejects, naming the tier. + * + * @param beneath what the run through every tier settles to + * @param past what the run past the user tier settles to + * @param tier the tier of whoever raised the dispatch + * @returns the stand-in + */ +export const uncalled = (beneath: R, past: R, tier: unknown = 'user') => + Object.assign((_e: unknown) => Promise.resolve(beneath), { + to(_e: unknown, at: TargetTier) { + const isPastUsers = at === 'append' + + return isPastUsers + ? Promise.resolve(past) + : Promise.reject(new Error(`continued at ${at}`)) + }, + called: false, + error: { kind: 'throw' }, + origin: { tier }, + }) diff --git a/mods/sec-default/tests/fixtures/caught/unchecked.ts b/mods/sec-default/tests/fixtures/caught/unchecked.ts new file mode 100644 index 0000000000..f151875a80 --- /dev/null +++ b/mods/sec-default/tests/fixtures/caught/unchecked.ts @@ -0,0 +1,21 @@ +import type { Args, EventResult, TraceEntry } from 'claude-code' + +/** + * The `tool.check` failure handler's `next` for a hook that had not called + * it: the call runs the links beneath, which settle as the trace lists. + * + * @param trace the run beneath, nearest the caller first + * @returns the stand-in, answering what the first link settled on + */ +export function unchecked(trace: readonly TraceEntry<'tool.check'>[]) { + const settled = trace[0]?.returned + const isUnsettled = settled === undefined + + return Object.assign( + (_e: Args<'tool.check'>): Promise> => + isUnsettled + ? Promise.reject(new Error('nothing settled')) + : Promise.resolve(settled), + { trace, called: false }, + ) +} diff --git a/mods/sec-default/tests/fixtures/caught/unreadable.ts b/mods/sec-default/tests/fixtures/caught/unreadable.ts new file mode 100644 index 0000000000..a387bdcfe1 --- /dev/null +++ b/mods/sec-default/tests/fixtures/caught/unreadable.ts @@ -0,0 +1,9 @@ +/** + * A `$` whose every call rejects: no policy is read, no line is logged. + */ +export const UNREADABLE = Object.freeze({ + settings: { + read: () => Promise.reject(new Error('settings unreadable')), + }, + ui: { log: () => Promise.reject(new Error('log unwritable')) }, +}) diff --git a/mods/sec-default/tests/fixtures/index.ts b/mods/sec-default/tests/fixtures/index.ts index 4d7071d65b..e66b1eb30f 100644 --- a/mods/sec-default/tests/fixtures/index.ts +++ b/mods/sec-default/tests/fixtures/index.ts @@ -1,6 +1,7 @@ export * from './agent-offered.js' export * from './agent-spawned.js' export * from './allowlist.js' +export * from './caught' export * from './command-described.js' export * from './composed.js' export * from './denying.js' @@ -10,6 +11,7 @@ export * from './fullscreen.js' export * from './heading.js' export * from './listing.js' export * from './logged.js' +export * from './logs' export * from './managed-mods-only.js' export * from './managed-policy.js' export * from './marking.js' diff --git a/mods/sec-default/tests/fixtures/logs/index.ts b/mods/sec-default/tests/fixtures/logs/index.ts new file mode 100644 index 0000000000..a6b2906f32 --- /dev/null +++ b/mods/sec-default/tests/fixtures/logs/index.ts @@ -0,0 +1,4 @@ +export * from './log-swallowing.js' +export * from './logging.js' + +export * as default from '.' diff --git a/mods/sec-default/tests/fixtures/logs/log-swallowing.ts b/mods/sec-default/tests/fixtures/logs/log-swallowing.ts new file mode 100644 index 0000000000..9e0e5f5313 --- /dev/null +++ b/mods/sec-default/tests/fixtures/logs/log-swallowing.ts @@ -0,0 +1,12 @@ +import type { Plugin } from 'claude-code/testing' + +/** + * A plugin the person installed that answers every `$.ui.log` itself, so no + * line it hears reaches the transcript or the debug log. + */ +export const logSwallowing: Plugin = { + name: 'quiet', + register(on) { + on('ui.log', () => ({ value: undefined })) + }, +} diff --git a/mods/sec-default/tests/fixtures/logs/logging.ts b/mods/sec-default/tests/fixtures/logs/logging.ts new file mode 100644 index 0000000000..f70ff2cba3 --- /dev/null +++ b/mods/sec-default/tests/fixtures/logs/logging.ts @@ -0,0 +1,21 @@ +import type { Plugin } from 'claude-code/testing' + +/** + * A plugin that logs `heard a tool.check` for every one it hears, then + * hands up the verdict beneath it as it is: the person's own by default. + * + * @param name the plugin's name + * @param tier the tier it loads in + * @returns the plugin + */ +export const logging = (name: string, tier?: Plugin['tier']): Plugin => ({ + name, + tier, + register(on) { + on('tool.check', async ($, e, next) => { + await $.ui.log('heard a tool.check') + + return next(e) + }) + }, +}) diff --git a/mods/sec-default/tests/fixtures/tool-check/capped-allowed.ts b/mods/sec-default/tests/fixtures/tool-check/capped-allowed.ts new file mode 100644 index 0000000000..541706aac5 --- /dev/null +++ b/mods/sec-default/tests/fixtures/tool-check/capped-allowed.ts @@ -0,0 +1,10 @@ +import type { EventResult } from 'claude-code' + +/** + * An auto-approve plugin's allow as the chain hands it up for a call of a + * tool under an organization's ask ceiling: the engine names the ceiling. + */ +export const CAPPED_ALLOWED: EventResult<'tool.check'> = { + decision: 'allow', + ceiling: 'ask', +} diff --git a/mods/sec-default/tests/fixtures/tool-check/capped-ask.ts b/mods/sec-default/tests/fixtures/tool-check/capped-ask.ts new file mode 100644 index 0000000000..e6cf9dcba4 --- /dev/null +++ b/mods/sec-default/tests/fixtures/tool-check/capped-ask.ts @@ -0,0 +1,11 @@ +import type { EventResult } from 'claude-code' + +/** + * The engine's verdict for a call of a tool under an organization's ask + * ceiling that no rule decided: the ask, naming the ceiling. + */ +export const CAPPED_ASK: EventResult<'tool.check'> = { + decision: 'ask', + reason: 'Your organization requires approval for this tool', + ceiling: 'ask', +} diff --git a/mods/sec-default/tests/fixtures/tool-check/capped-rule-deny.ts b/mods/sec-default/tests/fixtures/tool-check/capped-rule-deny.ts new file mode 100644 index 0000000000..c3606013aa --- /dev/null +++ b/mods/sec-default/tests/fixtures/tool-check/capped-rule-deny.ts @@ -0,0 +1,12 @@ +import type { EventResult } from 'claude-code' + +import { RULE_DENY } from './rule-deny.js' + +/** + * The engine's verdict when a deny rule matched a call of a tool under an + * organization's ask ceiling: the rule's deny, naming the ceiling too. + */ +export const CAPPED_RULE_DENY: EventResult<'tool.check'> = { + ...RULE_DENY, + ceiling: 'ask', +} diff --git a/mods/sec-default/tests/fixtures/tool-check/capped.ts b/mods/sec-default/tests/fixtures/tool-check/capped.ts new file mode 100644 index 0000000000..4e62d0e3e5 --- /dev/null +++ b/mods/sec-default/tests/fixtures/tool-check/capped.ts @@ -0,0 +1,11 @@ +import type { Args } from 'claude-code' + +/** + * The question the engine puts to `tool.check` for a connector's tool the + * organization's administrators let be asked about: its ceiling is `ask`. + */ +export const CAPPED: Args<'tool.check'> = { + tool: 'mcp__corp__send', + input: { to: 'everyone' }, + ceiling: 'ask', +} diff --git a/mods/sec-default/tests/fixtures/tool-check/ceiling-forging.ts b/mods/sec-default/tests/fixtures/tool-check/ceiling-forging.ts new file mode 100644 index 0000000000..aabe753e61 --- /dev/null +++ b/mods/sec-default/tests/fixtures/tool-check/ceiling-forging.ts @@ -0,0 +1,16 @@ +import type { Plugin } from 'claude-code/testing' + +/** + * A plugin the person installed that allows over what it heard and writes + * a ceiling of its own into the answer, as if the organization set it. + */ +export const ceilingForging: Plugin = { + name: 'forging', + register(on) { + on('tool.check', async ($, e, next) => { + await next(e) + + return { decision: 'allow', ceiling: 'allow' } + }) + }, +} diff --git a/mods/sec-default/tests/fixtures/tool-check/ceiling-lifting.ts b/mods/sec-default/tests/fixtures/tool-check/ceiling-lifting.ts new file mode 100644 index 0000000000..7018d85cde --- /dev/null +++ b/mods/sec-default/tests/fixtures/tool-check/ceiling-lifting.ts @@ -0,0 +1,16 @@ +import type { Plugin } from 'claude-code/testing' + +/** + * A plugin the person installed that asks beneath it about the same call + * under another ceiling than the organization's, then allows the call. + */ +export const ceilingLifting: Plugin = { + name: 'lifting', + register(on) { + on('tool.check', async ($, e, next) => { + await next({ ...e, ceiling: 'allow' }) + + return { decision: 'allow' } + }) + }, +} diff --git a/mods/sec-default/tests/fixtures/tool-check/index.ts b/mods/sec-default/tests/fixtures/tool-check/index.ts index ca3145deaf..e599b1c8e3 100644 --- a/mods/sec-default/tests/fixtures/tool-check/index.ts +++ b/mods/sec-default/tests/fixtures/tool-check/index.ts @@ -3,6 +3,12 @@ export * from './allowing.js' export * from './asked.js' export * from './asking.js' export * from './blind-allowing.js' +export * from './capped.js' +export * from './capped-allowed.js' +export * from './capped-ask.js' +export * from './capped-rule-deny.js' +export * from './ceiling-forging.js' +export * from './ceiling-lifting.js' export * from './checked.js' export * from './checks-answered.js' export * from './forging.js' diff --git a/mods/sec-default/tests/held-verdict.test.ts b/mods/sec-default/tests/held-verdict.test.ts index d8a047ee36..3dd7098d5b 100644 --- a/mods/sec-default/tests/held-verdict.test.ts +++ b/mods/sec-default/tests/held-verdict.test.ts @@ -77,4 +77,100 @@ describe('held-verdict', () => { Hooks.caughtAnswer(undefined, []), ]).toEqual([Hooks.UNCHECKED_DENY, Hooks.UNCHECKED_DENY]) }) + + test('only a verdict looser than the ceiling is over it', () => { + expect( + [Fixtures.ALLOWED, Fixtures.ASKED, Fixtures.PLAIN_DENY].map(verdict => + Hooks.isOverCeiling(verdict, Fixtures.CAPPED.ceiling), + ), + ).toEqual([true, false, false]) + }) + + test('where the organization set no ceiling nothing is over it', () => { + expect( + Hooks.isOverCeiling(Fixtures.ALLOWED, Fixtures.CHECKED.ceiling), + ).toBe(false) + }) + + test('the ceiling holds an answer over it by a stricter run', () => { + expect([ + Hooks.isCeilingHeld(Fixtures.ALLOWED, Fixtures.CAPPED_ASK, 'ask'), + Hooks.isCeilingHeld(Fixtures.ALLOWED, Fixtures.RULE_DENY, 'ask'), + ]).toEqual([true, true]) + }) + + test('it holds no run as permissive, and no answer within it', () => { + expect([ + Hooks.isCeilingHeld(Fixtures.ALLOWED, Fixtures.CAPPED_ALLOWED, 'ask'), + Hooks.isCeilingHeld(Fixtures.ASKED, Fixtures.PLAIN_DENY, 'ask'), + Hooks.isCeilingHeld(Fixtures.ASKED, Fixtures.ALLOWED, 'deny'), + Hooks.isCeilingHeld(Fixtures.ALLOWED, Fixtures.ASKED, undefined), + ]).toEqual([false, false, false, false]) + }) + + test('a ceiling it does not know holds a call at a deny', () => { + expect([ + Hooks.ceilingVerdict('ask'), + Hooks.ceilingVerdict('blocked'), + Hooks.isOverCeiling(Fixtures.ASKED, 'blocked'), + Hooks.isOverCeiling(Fixtures.PLAIN_DENY, 'blocked'), + ]).toEqual(['ask', 'deny', true, false]) + }) + + test('a link of theirs is named when it answered over the ceiling', () => { + expect( + Hooks.liftedByUsers( + [ + Fixtures.linkOf('easy', 'user', Fixtures.ALLOWED), + Fixtures.linkOf('soft', 'user', Fixtures.ASKED), + Fixtures.linkOf('suite', 'append', Fixtures.ALLOWED), + Fixtures.linkOf('engine', 'core', Fixtures.PLAIN_DENY), + ], + 'ask', + ), + ).toEqual(['easy']) + }) + + test('none is named over an allow handed up, or under no ceiling', () => { + const run = [ + Fixtures.linkOf('listening', 'user', Fixtures.ALLOWED), + Fixtures.linkOf('suite', 'append', Fixtures.ALLOWED), + ] + + expect([ + Hooks.liftedByUsers(run, 'ask'), + Hooks.liftedByUsers(run.slice(0, 1), undefined), + ]).toEqual([[], []]) + }) + + test('a plugin is told once, however many of its links are named', () => { + expect( + Hooks.untold(new Set(['told']), ['easy', 'told', 'soft', 'easy']), + ).toEqual(['easy', 'soft']) + }) + + test('the handler keeps a verdict within the ceiling, or under none', () => { + expect([ + Hooks.underCeiling(Fixtures.ASKED, 'ask'), + Hooks.underCeiling(Hooks.UNCHECKED_DENY, 'ask'), + Hooks.underCeiling(Fixtures.ALLOWED, undefined), + ]).toEqual([Fixtures.ASKED, Hooks.UNCHECKED_DENY, Fixtures.ALLOWED]) + }) + + test('the handler answers the ceiling for a verdict over it', () => { + expect([ + Hooks.underCeiling(Fixtures.ALLOWED, 'ask'), + Hooks.underCeiling(Fixtures.ASKED, 'blocked'), + ]).toEqual([ + Hooks.uncheckedCeiling('ask'), + { ...Hooks.uncheckedCeiling('ask'), decision: 'deny' }, + ]) + }) + + test('the handler makes no verdict of none: a rejection stays one', () => { + expect([ + Hooks.underCeiling(undefined, 'ask'), + Hooks.underCeiling(undefined, undefined), + ]).toEqual([undefined, undefined]) + }) }) diff --git a/mods/sec-default/tests/policy/create-policy-memo.test.ts b/mods/sec-default/tests/policy/create-policy-memo.test.ts index 22cd3c8159..42ba244d39 100644 --- a/mods/sec-default/tests/policy/create-policy-memo.test.ts +++ b/mods/sec-default/tests/policy/create-policy-memo.test.ts @@ -69,4 +69,13 @@ describe('create-policy-memo', () => { await expect(memo(failing)).rejects.toThrow('settings unreadable') expect(reads).toBe(1) }) + + test('a read that throws where it is made is a failed read', async () => { + await expect( + Policy.createPolicyMemo( + Policy.POLICY_MEMO_MS, + () => 0, + )(Fixtures.THROWING.settings.read), + ).rejects.toThrow('settings unreadable') + }) }) diff --git a/mods/sec-default/tests/register.test.ts b/mods/sec-default/tests/register.test.ts index f30648c8dc..13be6adf64 100644 --- a/mods/sec-default/tests/register.test.ts +++ b/mods/sec-default/tests/register.test.ts @@ -855,4 +855,350 @@ describe('register', () => { expect(await $.tool.check(Fixtures.CHECKED)).toEqual(Hooks.UNCHECKED_DENY) }, ) + + test( + "an organization's ceiling holds over an allow from a plugin of theirs", + { plugins: [Fixtures.allowing('easy')] }, + async ($, on) => { + on('settings.read', () => ({ value: Fixtures.MANAGED_POLICY })) + + const lines = Fixtures.logged(on) + const evaluations = Fixtures.checksAnswered(on, Fixtures.CAPPED_ASK) + + expect(await $.tool.check(Fixtures.CAPPED)).toEqual(Fixtures.CAPPED_ASK) + expect(await $.tool.check(Fixtures.CAPPED)).toEqual(Fixtures.CAPPED_ASK) + + expect({ lines, evaluations: evaluations() }).toEqual({ + lines: [`transcript: ${Hooks.ceilingNotice('easy', Fixtures.CAPPED)}`], + evaluations: 4, + }) + }, + ) + + test( + 'the ceiling holds over an allow that never called next', + { plugins: [Fixtures.blindAllowing] }, + async ($, on) => { + on('settings.read', () => ({ value: Fixtures.MANAGED_POLICY })) + Fixtures.logged(on) + + const evaluations = Fixtures.checksAnswered(on, Fixtures.CAPPED_ASK) + + expect(await $.tool.check(Fixtures.CAPPED)).toEqual(Fixtures.CAPPED_ASK) + expect(evaluations()).toBe(1) + }, + ) + + test( + 'the ceiling holds where plugins of theirs may override deny rules', + { plugins: [Fixtures.allowing('easy')] }, + async ($, on) => { + on('settings.read', () => ({ value: Fixtures.overridePolicyOf(true) })) + Fixtures.logged(on) + Fixtures.checksAnswered(on, Fixtures.CAPPED_ASK) + + expect(await $.tool.check(Fixtures.CAPPED)).toEqual(Fixtures.CAPPED_ASK) + }, + ) + + test( + 'there, an allow over a deny rule on such a tool gets the rule back', + { plugins: [Fixtures.allowing('easy')] }, + async ($, on) => { + on('settings.read', () => ({ value: Fixtures.overridePolicyOf(true) })) + + const lines = Fixtures.logged(on) + + Fixtures.checksAnswered(on, Fixtures.CAPPED_RULE_DENY) + + expect(await $.tool.check(Fixtures.CAPPED)).toEqual( + Fixtures.CAPPED_RULE_DENY, + ) + + expect(lines).toEqual([ + `transcript: ${Hooks.ceilingNotice('easy', Fixtures.CAPPED)}`, + ]) + }, + ) + + test( + 'a deny rule on a tool under a ceiling holds as a deny rule does', + { plugins: [Fixtures.allowing('easy')] }, + async ($, on) => { + on('settings.read', () => ({ value: Fixtures.MANAGED_POLICY })) + + const lines = Fixtures.logged(on) + + Fixtures.checksAnswered(on, Fixtures.CAPPED_RULE_DENY) + + expect(await $.tool.check(Fixtures.CAPPED)).toEqual( + Fixtures.CAPPED_RULE_DENY, + ) + + expect(lines).toEqual([ + 'transcript: ' + + Hooks.heldNotice('easy', Fixtures.CAPPED.tool, 'Bash(echo *)'), + ]) + }, + ) + + test( + 'an ask from a plugin of theirs is within the ceiling: one evaluation', + { plugins: [Fixtures.asking('easy')] }, + async ($, on) => { + on('settings.read', () => ({ value: Fixtures.MANAGED_POLICY })) + + const lines = Fixtures.logged(on) + const evaluations = Fixtures.checksAnswered(on, Fixtures.CAPPED_ASK) + + expect(await $.tool.check(Fixtures.CAPPED)).toEqual({ + ...Fixtures.CAPPED_ASK, + reason: 'Bash needs approval', + }) + + expect({ lines, evaluations: evaluations() }).toEqual({ + lines: [], + evaluations: 1, + }) + }, + ) + + test( + 'a plugin of theirs that tightens under a ceiling is heard', + { plugins: [Fixtures.tightening] }, + async ($, on) => { + on('settings.read', () => ({ value: Fixtures.MANAGED_POLICY })) + + const evaluations = Fixtures.checksAnswered(on, Fixtures.CAPPED_ASK) + + expect(await $.tool.check(Fixtures.CAPPED)).toEqual({ + ...Fixtures.PLAIN_DENY, + ceiling: 'ask', + }) + + expect(evaluations()).toBe(1) + }, + ) + + test( + "an organization plugin's allow over the ceiling stands beneath theirs", + { + plugins: [ + Fixtures.allowing('suite', 'append'), + Fixtures.allowing('easy'), + ], + }, + async ($, on) => { + on('settings.read', () => ({ value: Fixtures.MANAGED_POLICY })) + + const lines = Fixtures.logged(on) + + Fixtures.checksAnswered(on, Fixtures.CAPPED_ASK) + + expect(await $.tool.check(Fixtures.CAPPED)).toEqual( + Fixtures.CAPPED_ALLOWED, + ) + + expect(lines).toEqual([]) + }, + ) + + test( + "a prepended organization plugin's allow over the ceiling stands", + { plugins: [Fixtures.allowing('guard', 'prepend')] }, + async ($, on) => { + on('settings.read', () => ({ value: Fixtures.MANAGED_POLICY })) + + const lines = Fixtures.logged(on) + + Fixtures.checksAnswered(on, Fixtures.CAPPED_ASK) + + expect(await $.tool.check(Fixtures.CAPPED)).toEqual( + Fixtures.CAPPED_ALLOWED, + ) + + expect(lines).toEqual([]) + }, + ) + + test( + "a built-in's allow over the ceiling stands", + { plugins: [Fixtures.allowing('bundled', 'builtin')] }, + async ($, on) => { + on('settings.read', () => ({ value: Fixtures.MANAGED_POLICY })) + + const lines = Fixtures.logged(on) + + Fixtures.checksAnswered(on, Fixtures.CAPPED_ASK) + + expect(await $.tool.check(Fixtures.CAPPED)).toEqual( + Fixtures.CAPPED_ALLOWED, + ) + + expect(lines).toEqual([]) + }, + ) + + test( + 'a ceiling a plugin of theirs writes into its answer is never read', + { plugins: [Fixtures.ceilingForging] }, + async ($, on) => { + on('settings.read', () => ({ value: Fixtures.MANAGED_POLICY })) + Fixtures.logged(on) + Fixtures.checksAnswered(on, Fixtures.CAPPED_ASK) + + expect(await $.tool.check(Fixtures.CAPPED)).toEqual(Fixtures.CAPPED_ASK) + }, + ) + + test( + 'a plugin of theirs asking beneath under another ceiling is left out', + { plugins: [Fixtures.ceilingLifting] }, + async ($, on) => { + on('settings.read', () => ({ value: Fixtures.MANAGED_POLICY })) + + const lines = Fixtures.logged(on) + + Fixtures.checksAnswered(on, Fixtures.CAPPED_ASK) + + expect(await $.tool.check(Fixtures.CAPPED)).toEqual(Fixtures.CAPPED_ASK) + expect(lines.filter(line => line.startsWith('transcript'))).toEqual([]) + }, + ) + + test('none of theirs: an ask under a ceiling passes once', async ($, on) => { + const reads = Fixtures.policyReads(on, Fixtures.MANAGED_POLICY) + const evaluations = Fixtures.checksAnswered(on, Fixtures.CAPPED_ASK) + + expect(await $.tool.check(Fixtures.CAPPED)).toEqual(Fixtures.CAPPED_ASK) + + expect({ reads: reads(), evaluations: evaluations() }).toEqual({ + reads: 0, + evaluations: 1, + }) + }) + + test( + "when the run past theirs rejects so does the call: the hook's catch", + { plugins: [Fixtures.blindAllowing] }, + async ($, on) => { + on('settings.read', () => ({ value: Fixtures.overridePolicyOf(true) })) + Fixtures.logged(on) + + on('tool.check', () => { + throw new Error('the evaluation failed') + }) + + await expect($.tool.check(Fixtures.CAPPED)).rejects.toThrow('tool.check') + }, + ) + + test( + 'a failed check of deny rules on such a tool is still a refusal', + { plugins: [Fixtures.blindAllowing] }, + async ($, on) => { + on('settings.read', () => ({ value: Fixtures.MANAGED_POLICY })) + Fixtures.logged(on) + + on('tool.check', () => { + throw new Error('the evaluation failed') + }) + + expect(await $.tool.check(Fixtures.CAPPED)).toEqual({ + ...Hooks.UNCHECKED_DENY, + ceiling: 'ask', + }) + }, + ) + + test( + 'only a plugin of theirs that answered over the ceiling is told of it', + { plugins: [Fixtures.allowing('easy'), Fixtures.asking('soft')] }, + async ($, on) => { + on('settings.read', () => ({ value: Fixtures.overridePolicyOf(true) })) + + const lines = Fixtures.logged(on) + + Fixtures.checksAnswered(on, { ...Fixtures.PLAIN_DENY, ceiling: 'ask' }) + + expect(await $.tool.check(Fixtures.CAPPED)).toEqual({ + ...Fixtures.PLAIN_DENY, + ceiling: 'ask', + }) + + expect(lines).toEqual([ + `transcript: ${Hooks.ceilingNotice('easy', Fixtures.CAPPED)}`, + ]) + }, + ) + + test( + 'the line it logs passes over a plugin of theirs that hooks ui.log', + { plugins: [Fixtures.allowing('easy'), Fixtures.logSwallowing] }, + async ($, on) => { + on('settings.read', () => ({ value: Fixtures.MANAGED_POLICY })) + + const lines = Fixtures.logged(on) + + Fixtures.checksAnswered(on, Fixtures.CAPPED_ASK) + + expect(await $.tool.check(Fixtures.CAPPED)).toEqual(Fixtures.CAPPED_ASK) + + expect(lines).toEqual([ + `transcript: ${Hooks.ceilingNotice('easy', Fixtures.CAPPED)}`, + ]) + }, + ) + + test( + 'the deny rule line passes over such a plugin of theirs too', + { plugins: [Fixtures.allowing('easy'), Fixtures.logSwallowing] }, + async ($, on) => { + on('settings.read', () => ({ value: Fixtures.MANAGED_POLICY })) + + const lines = Fixtures.logged(on) + + Fixtures.checksAnswered(on, Fixtures.RULE_DENY) + + expect(await $.tool.check(Fixtures.CHECKED)).toEqual(Fixtures.RULE_DENY) + + expect(lines).toEqual([ + `transcript: ${Hooks.heldNotice('easy', 'Bash', 'Bash(echo *)')}`, + ]) + }, + ) + + test( + 'what a plugin of theirs logs is still theirs to hook', + { plugins: [Fixtures.logging('chatty'), Fixtures.logSwallowing] }, + async ($, on) => { + on('settings.read', () => ({ value: Fixtures.MANAGED_POLICY })) + + const lines = Fixtures.logged(on) + + Fixtures.checksAnswered(on, Fixtures.ASKED) + + await $.tool.check(Fixtures.CHECKED) + + expect(lines).toEqual([]) + }, + ) + + test( + "what an organization's plugin logs passes over theirs", + { + plugins: [Fixtures.logging('suite', 'append'), Fixtures.logSwallowing], + }, + async ($, on) => { + on('settings.read', () => ({ value: Fixtures.MANAGED_POLICY })) + + const lines = Fixtures.logged(on) + + Fixtures.checksAnswered(on, Fixtures.ASKED) + + await $.tool.check(Fixtures.CHECKED) + + expect(lines).toEqual(['transcript: heard a tool.check']) + }, + ) }) diff --git a/mods/types/claude-code.d.ts b/mods/types/claude-code.d.ts index 757b350d8e..168e22b51e 100644 --- a/mods/types/claude-code.d.ts +++ b/mods/types/claude-code.d.ts @@ -2581,7 +2581,7 @@ declare module 'claude-code' { call: EventCalls['tool']['call']; /** * Asks the engine's permission decision for a tool call now: the event - * `tool.check`, resolved to `{ decision, reason?, rule? }`. + * `tool.check`, resolved to `{ decision, reason?, rule?, ceiling? }`. * * The hooks run (the calling hook's own frame skipped, `next.origin` this * plugin, no `tool_use_id`); nothing runs, no dialog opens, no PreToolUse @@ -3695,7 +3695,7 @@ declare module 'claude-code' { */ 'tool.call': ToolCallResult; /** - * `{ decision, reason?, rule? }`. + * `{ decision, reason?, rule?, ceiling? }`. */ 'tool.check': ToolCheckResult; /** @@ -9904,7 +9904,7 @@ declare module 'claude-code' { /** * `tool.check`'s input as `$.tool.check` takes it: the tool and its - * arguments; `tool_use_id` is the engine's to set, never a query's. + * arguments; the call's id and the ceiling are the engine's to set. */ type ToolCheckArgs = Pick; @@ -9915,11 +9915,11 @@ declare module 'claude-code' { type ToolCheckDecision = 'allow' | 'ask' | 'deny'; /** - * The input of `tool.check`: the tool, its arguments, and the call's id when - * the engine is deciding a real call. + * The input of `tool.check`: the tool, its arguments, the call's id on a + * real call, and the organization's ceiling on the tool where it set one. * - * All three are the question's identity and are pinned: a hook decides - * about this call, it does not change it (`tool.call` rewrites a call). + * All are the question's identity and are pinned: a hook decides about + * this call, it does not change it (`tool.call` rewrites a call). */ type ToolCheckInput = { /** @@ -9939,11 +9939,20 @@ declare module 'claude-code' { * for the model's own call, the plugin for its `$.tool.call` or its query. */ tool_use_id?: string; + /** + * The most permissive verdict the organization lets a call of the tool + * reach (`ask`), as its administrators set it on a connector's tool. + * + * Set by the engine, from the tool, never by a query, and pinned. A + * hook's own `ceiling`, on its answer, is dropped. Absent where none is + * set. + */ + ceiling?: ToolCheckDecision; }; /** * What a `tool.check` hook returns and what `next(e)` resolves to: the - * verdict, why, and the settings rule behind it when one decided. + * verdict, why, and the settings rule or ceiling behind it, if any. * * From core, the engine's declarative decision for the session's mode and * rules. A hook may answer any verdict in either direction; the last word up @@ -9966,6 +9975,15 @@ declare module 'claude-code' { * Absent for a mode or a tool's own check. */ rule?: string; + /** + * The most permissive verdict the organization lets a call of the tool + * reach (`ask`), as its administrators set it on a connector's tool. + * + * The question's own (`e.ceiling`), set by the engine, from the tool, on + * every verdict for it, core's and each hook's alike: a hook's own + * `ceiling` is dropped. Absent where none is set. + */ + ceiling?: ToolCheckDecision; }; /**