From 1169a7fdf26af6257043efc5802dc414ef7c3fec Mon Sep 17 00:00:00 2001 From: Alice Poteat Date: Fri, 2 Oct 2026 20:38:11 -0700 Subject: [PATCH 1/2] sec-default: a person's plugin may tighten, never loosen, what holds over it Where this plugin is seated, a plugin a person installs no longer answers more permissively than any deny on tool.check, an ask a settings rule or a classic hook decided, or a variable managed settings set in env. The lines an organization's plugins log, this one's included, continue past the user tier. --- mods/README.md | 2 +- mods/sec-default/.claude-plugin/plugin.json | 2 +- mods/sec-default/README.md | 77 ++-- .../hooks/held-verdict/caught-answer.ts | 2 +- .../hooks/held-verdict/held-kind.ts | 19 + .../hooks/held-verdict/held-notice.ts | 31 +- mods/sec-default/hooks/held-verdict/index.ts | 1 + .../hooks/held-verdict/verdicts/holds-over.ts | 24 ++ .../hooks/held-verdict/verdicts/index.ts | 3 +- .../held-verdict/verdicts/is-rule-deny.ts | 18 - .../held-verdict/verdicts/types/index.ts | 3 - .../held-verdict/verdicts/types/rule-deny.ts | 9 - .../held-verdict/verdicts/unchecked-deny.ts | 6 +- mods/sec-default/hooks/hooks.json | 2 +- mods/sec-default/hooks/index.ts | 1 + .../hooks/pinned-variable-refusal/index.ts | 3 + .../pinned-variable-refusal.ts | 12 + .../hooks/policy/deny-rules-hold.ts | 5 +- mods/sec-default/hooks/policy/index.ts | 1 + .../hooks/policy/is-pinned-variable.ts | 14 + mods/sec-default/hooks/register.ts | 45 +- mods/sec-default/tests/fixtures/index.ts | 9 + mods/sec-default/tests/fixtures/logging.ts | 21 + .../fixtures/lower-case-pinning-policy.ts | 8 + .../tests/fixtures/pinning-policy.ts | 8 + mods/sec-default/tests/fixtures/revaluing.ts | 12 + .../tests/fixtures/setting-lower-case.ts | 16 + .../tests/fixtures/setting-other.ts | 21 + .../tests/fixtures/setting-proxy.ts | 23 + .../tool-check/checks-answered-in-turn.ts | 24 ++ .../tests/fixtures/tool-check/hook-ask.ts | 11 + .../tests/fixtures/tool-check/hook-deny.ts | 11 + .../tests/fixtures/tool-check/index.ts | 6 + .../tests/fixtures/tool-check/muting.ts | 13 + .../tests/fixtures/tool-check/plain-deny.ts | 6 +- .../tests/fixtures/tool-check/rule-ask.ts | 11 + .../tests/fixtures/tool-check/sneaking.ts | 25 ++ .../tests/fixtures/tool-check/tightening.ts | 2 +- .../tests/fixtures/unsetting-proxy.ts | 16 + .../tests/fixtures/variables-set.ts | 22 + mods/sec-default/tests/held-verdict.test.ts | 78 +++- .../tests/policy/is-pinned-variable.test.ts | 32 ++ mods/sec-default/tests/register.test.ts | 404 +++++++++++++++++- mods/types/claude-code.d.ts | 11 +- 44 files changed, 973 insertions(+), 97 deletions(-) create mode 100644 mods/sec-default/hooks/held-verdict/held-kind.ts create mode 100644 mods/sec-default/hooks/held-verdict/verdicts/holds-over.ts delete mode 100644 mods/sec-default/hooks/held-verdict/verdicts/is-rule-deny.ts delete mode 100644 mods/sec-default/hooks/held-verdict/verdicts/types/index.ts delete mode 100644 mods/sec-default/hooks/held-verdict/verdicts/types/rule-deny.ts create mode 100644 mods/sec-default/hooks/pinned-variable-refusal/index.ts create mode 100644 mods/sec-default/hooks/pinned-variable-refusal/pinned-variable-refusal.ts create mode 100644 mods/sec-default/hooks/policy/is-pinned-variable.ts create mode 100644 mods/sec-default/tests/fixtures/logging.ts create mode 100644 mods/sec-default/tests/fixtures/lower-case-pinning-policy.ts create mode 100644 mods/sec-default/tests/fixtures/pinning-policy.ts create mode 100644 mods/sec-default/tests/fixtures/revaluing.ts create mode 100644 mods/sec-default/tests/fixtures/setting-lower-case.ts create mode 100644 mods/sec-default/tests/fixtures/setting-other.ts create mode 100644 mods/sec-default/tests/fixtures/setting-proxy.ts create mode 100644 mods/sec-default/tests/fixtures/tool-check/checks-answered-in-turn.ts create mode 100644 mods/sec-default/tests/fixtures/tool-check/hook-ask.ts create mode 100644 mods/sec-default/tests/fixtures/tool-check/hook-deny.ts create mode 100644 mods/sec-default/tests/fixtures/tool-check/muting.ts create mode 100644 mods/sec-default/tests/fixtures/tool-check/rule-ask.ts create mode 100644 mods/sec-default/tests/fixtures/tool-check/sneaking.ts create mode 100644 mods/sec-default/tests/fixtures/unsetting-proxy.ts create mode 100644 mods/sec-default/tests/fixtures/variables-set.ts create mode 100644 mods/sec-default/tests/policy/is-pinned-variable.test.ts diff --git a/mods/README.md b/mods/README.md index 49bf21eed6..4f19aa5b96 100644 --- a/mods/README.md +++ b/mods/README.md @@ -7,7 +7,7 @@ source, published as it is built into the binary. | Mod | What it does | Seated | | --- | --- | --- | -| [`sec-default`](sec-default) | Keeps an organization's classic hooks, prompt content, managed settings, tool policy and deny rules out of reach of the plugins a person installs; adds no policy of its own. | Outermost, on a machine with managed settings or for a Team or Enterprise organization, unless managed `prependPlugins` says otherwise | +| [`sec-default`](sec-default) | Keeps an organization's classic hooks, prompt content, managed settings, tool policy, permission rules and pinned environment out of reach of the plugins a person installs; adds no policy of its own. | Outermost, on a machine with managed settings or for a Team or Enterprise organization, unless managed `prependPlugins` says otherwise | | [`diff`](diff) | `/diff`: the session's uncommitted changes in a pane beside the transcript, file by file with their hunks, refreshed as Claude edits files and runs commands. | Built in | | [`telemetry`](telemetry) | Hooks `$.telemetry`'s two events (`log`, `mark`), adding the noun in the `engine.create` fold where the engine has none, so a built-in plugin can record an event as a first-party analytics row, sent in batches; refuses installed plugins; sends nothing wherever Claude Code's analytics are off. | Built in | | [`agents-md`](agents-md) | `AGENTS.md` as project instructions, by one option: loaded where the project has no `CLAUDE.md` of its own (`claude-md-or-agents-md`, the default) or beside it (`claude-md-and-agents-md`), placed and framed exactly as the engine places `CLAUDE.md`, nested ones on a `Read`; or the project's and the person's instruction files dropped and the organization's kept (`managed-only`); or `CLAUDE.md` alone, as the engine reads it (`claude-md`). | Built in | diff --git a/mods/sec-default/.claude-plugin/plugin.json b/mods/sec-default/.claude-plugin/plugin.json index 5cffa2607f..32efacdeb1 100644 --- a/mods/sec-default/.claude-plugin/plugin.json +++ b/mods/sec-default/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "sec-default", "version": "0.1.0", - "description": "Security default for organizations: seated outermost, it keeps the organization's classic hooks, prompt content, settings and tool policy out of reach of the plugins a person installs, and adds no policy of its own; one managed option, allowManagedModsOnly, limits mods to the organization's.", + "description": "Security default for organizations: seated outermost, it keeps the organization's classic hooks, prompt content, settings, tool policy and pinned environment out of reach of the plugins a person installs, and adds no policy of its own; one managed option, allowManagedModsOnly, limits mods to the organization's.", "author": { "name": "Anthropic" } diff --git a/mods/sec-default/README.md b/mods/sec-default/README.md index 98bbc1c2c2..d9de60c569 100644 --- a/mods/sec-default/README.md +++ b/mods/sec-default/README.md @@ -32,9 +32,11 @@ settings it decides by. | `tool.describe`, `command.describe`, `agent.offer`, `agent.spawn` | When the subject's pinned `e.provider.tier` is `prepend` or `append` (a policy-installed plugin, the managed folder, a policy MCP server), continue past the user tier; a subject provided by `user`, `builtin` or `core` passes. | | `tool.register` | A caller in `prepend` or `append` continues past the user tier. A `user`-tier caller is refused by name while managed settings hold `allowedMcpServers` (set at all, empty included); otherwise it passes. | | `tool.list` | The tools of the organization's managed MCP servers are listed as the organization's tiers listed them; every other tool as the user tier left it. With no policy to read, or a refusal from either listing, the organization's listing stands whole. | -| `tool.check` | A deny that a settings rule decided holds over the user tier: when a person's plugin loosened the verdict it was handed, the dispatch is run again past the user tier, and if that verdict is a deny naming its rule, it is the answer. See [Deny rules hold](#deny-rules-hold). Every other verdict passes as the chain left it. | +| `tool.check` | A deny, and an ask a settings rule or a classic hook decided, hold over the user tier: when a person's plugin loosened the verdict it was handed, the dispatch is run again past the user tier, and if that verdict is the stricter and is a deny, or names the rule or the hook behind it, it is the answer. See [Deny rules hold](#deny-rules-hold). Every other verdict passes as the chain left it. | +| `ui.log` | A line a plugin in `prepend` or `append` logs, this one's included, continues past the user tier: no user hook rewrites or drops it, and none runs inside the hook that logged it. Every other line passes. | +| `env.set` | A variable managed settings set in `env` (its name in any case) is pinned: setting or unsetting it, a `user`-tier caller is refused by name and any other caller continues past the user tier, so no user hook rewrites the value. A variable the organization does not set passes. With no policy to read, every variable counts as pinned. `allowModsToOverrideDenyRules` does not unpin. | | `plugin.register` | A hooks module in the `user` tier (one a person installed, named with `--plugin-dir`, or keeps in their mods folder) is refused while managed settings set this plugin's `allowManagedModsOnly` option; otherwise it passes. Modules in `prepend`, `append` and `builtin` are never asked about. | -| everything else | Passes: `prompt.submit`, `turn.*`, `tool.call`, `command.run`, `command.register`, `session.*`, `ui.*`, `fs.*`, `http.fetch`, `process.run`, `store.*`, `clock.*`, `model.*`, `mcp.call`, `audio.*`, `agent.list`, `engine.create`. | +| everything else | Passes: `prompt.submit`, `turn.*`, `tool.call`, `command.run`, `command.register`, `session.*`, `ui.*` but `ui.log`, `fs.*`, `http.fetch`, `process.run`, `env.get`, `store.*`, `clock.*`, `model.*`, `mcp.call`, `audio.*`, `agent.list`, `engine.create`. | ## Options an administrator sets @@ -87,25 +89,28 @@ not loaded. Settings hooks, status lines and `/goal` are not touched by it. `allowModsToOverrideDenyRules`: the plugins a person installs may answer over a settings deny rule on `tool.check`, as they could before this plugin -held deny rules. Off unless it is the literal `true`; an option that reads -as unset leaves deny rules holding. See [Deny rules hold](#deny-rules-hold). +held deny rules, and over every other deny, an ask rule or a classic hook's +ask with it: the one option covers all that holds there. Off unless it is the +literal `true`; an option that reads as unset leaves them holding. See +[Deny rules hold](#deny-rules-hold). ## What it hooks `classic.*`, `prompt.section`, `prompt.context`, `prompt.compose`, `skill.prompt`, `attribution.text`, `settings.read`, `tool.describe`, `command.describe`, `agent.offer`, `agent.spawn`, `tool.register`, `tool.list`, `tool.check`, -`plugin.register`. +`ui.log`, `env.set`, `plugin.register`. Hooking `tool.check` has a cost: the engine raises that event only when some loaded plugin hooks it, so where this plugin is seated every tool call now runs the `tool.check` chain, where before only a session with such a plugin -did. +did. So has hooking `ui.log` and `env.set`: each such call of any plugin's now +runs a chain, and an `env.set` waits on the policy read. ## What it calls on `$` `settings.read`, and `ui.log`: to the debug log, and for the one line a -person reads when a deny rule held over a plugin of theirs. It continues to +person reads when something held over a plugin of theirs. It continues to the `append` tier with `next.to`, which only a plugin in a managed tier may do. @@ -113,8 +118,8 @@ do. On `tool.check` any hook may answer any verdict, so a plugin a person installs to stop the permission prompts (`() => ({ decision: "allow" })`) -would also lift a deny rule, a managed one included. Where this plugin is -seated it does not: +would also lift a deny rule, an ask rule or a `PreToolUse` hook's ask, a +managed one included. Where this plugin is seated it does not: - The hook first runs the chain as it is. If the answer is a deny, or no link that may hold a person's plugin answered more permissively than the @@ -131,34 +136,54 @@ seated it does not: plugin, so neither the decision nor the rule it names can have been rewritten or erased, and a plugin that answered without calling `next` changes nothing: the rules are evaluated in this run. The two runs differ - by the user tier alone, so a deny here that names its rule is a deny rule - the user tier loosened, and it is returned in place of the chain's answer. -- Any deny rule counts, whatever settings file it came from: a verdict - carries the rule as written, never where it was read from. A deny that - names no rule (a settings hook's, a tool's own check) is not held. + by the user tier alone, so a verdict here that is stricter than the chain's + answer is one the user tier loosened. A deny is returned in place of the + chain's answer whatever decided it; an ask, when it names what decided it: + a settings rule (`rule`) or a classic hook (`hook`). +- Any rule and any classic hook counts, whoever configured it: a verdict + carries the rule as written and the hook's event, never where either was + read from. A deny holds unnamed because it can stand in front of a rule's or + a hook's ask: lifted, the call would run with nobody asked. An ask that names + neither (the mode's own, a check of the engine's own) is not held. - An organization's plugin (prepend or append) or a built-in that allows over a deny rule takes part in both runs, so its answer stands (a prepended one that loosens is what brings the second run about, so its hooks run - twice on such a call). An ask - that a person's plugin turns into an allow, with no deny rule behind it, + twice on such a call). The mode's own ask + that a person's plugin turns into an allow, with no rule or hook behind it, stands: that is what such a plugin is for. - `tool.check` pins the question (`tool`, `input`, `tool_use_id`), so no hook can have the rules evaluated on one command and another run; a rewrite belongs to `tool.call`, which runs before any of this. -- The person is told once for each name in a session, in the transcript - and the debug log: ` tried to lift a deny rule in your settings - from a call (); the deny rule holds over the plugins you - install (allowModsToOverrideDenyRules)`. Plugins the engine ran as one +- The person is told once for each name and kind in a session, in the + transcript and the debug log: ` tried to lift a deny rule in your + settings from a call (); the deny rule holds over the plugins + you install (allowModsToOverrideDenyRules)`. The kinds are `a deny rule`, + `an ask rule`, `a hook's ask` and `a refusal` (a deny that names no + rule); the last two carry neither `in your settings` nor a rule. Plugins the + engine ran as one batch are named together, as it names them (`audit+easy`). A plain `-p` - run has it in the debug log alone; the call is still denied with the - rule's own message. + run has it in the debug log alone; the call is still denied, or asked + about, with its own message. The line goes past the user tier (the `ui.log` + row): a hook of theirs that heard it would run inside this plugin's own + `tool.check` hook, and the engine leaves a hook out of whatever is raised + from inside it. +- What this does not reach: a held ask is still an ask, so whatever answers + asks for the person answers it, a classic `PermissionRequest` hook of + theirs included (`allowManagedHooksOnly` is the control for those). And a + command hook decides only if its command runs: it is spawned in the + session's environment and a hook that fails to run decides nothing, so a + variable its spawn depends on (`PATH`, `CLAUDE_CODE_SHELL_PREFIX`) that the + organization does not set in managed `env` is a person's to change, by their + own settings as by a plugin's `$.env.set`. Set in managed `env`, it is + pinned (the `env.set` row). - If the hook itself fails, its `.catch` answers from the one run it can read: a deny stands; a verdict no plugin of the person's loosened stands; - one they loosened, or a run that rejected, is refused, since the deny - rules were never consulted. + one they loosened, or a run that rejected, is refused, since the rules + and hooks were never consulted. An organization that wants the plugins its people install to override deny -rules says so in managed settings, under this plugin's own options: +rules, and all else that holds with them, says so in managed settings, under +this plugin's own options: ```json { @@ -173,7 +198,7 @@ rules says so in managed settings, under this plugin's own options: Only the managed source is read (`$.settings.read({ source: "policy" })`), so the same key in a person's, a project's or a local settings file, or in `--settings`, is never consulted; only the literal `true` counts, and a -policy that cannot be read leaves deny rules holding. +policy that cannot be read leaves them all holding. ## Where it is seated diff --git a/mods/sec-default/hooks/held-verdict/caught-answer.ts b/mods/sec-default/hooks/held-verdict/caught-answer.ts index 0ece94248a..9f78154977 100644 --- a/mods/sec-default/hooks/held-verdict/caught-answer.ts +++ b/mods/sec-default/hooks/held-verdict/caught-answer.ts @@ -7,7 +7,7 @@ import Verdicts from './verdicts' * can read, the failed hook's last: that run's verdict, or a refusal. * * A deny stands, and so does a verdict no link that may hold a person's - * plugin loosened. A loosened one, or none at all, met no deny rule. + * plugin loosened. A loosened one, or none at all, met no rule or hook. * * @param last what that run settled on; undefined when it rejected * @param trace that run's `next.trace` diff --git a/mods/sec-default/hooks/held-verdict/held-kind.ts b/mods/sec-default/hooks/held-verdict/held-kind.ts new file mode 100644 index 0000000000..f622cc5735 --- /dev/null +++ b/mods/sec-default/hooks/held-verdict/held-kind.ts @@ -0,0 +1,19 @@ +import type { EventResult } from 'claude-code' + +/** + * What kind of thing held over a person's plugin, as the notice calls it: + * `deny rule`, `ask rule`, ` hook's ask`, or `refusal`. + * + * A deny that names no rule is a refusal even when it names a hook: the hook + * may only have asked, and something else refused. + * + * @param held the verdict that holds + * @returns the kind + */ +export function heldKind(held: EventResult<'tool.check'>) { + if (held.rule !== undefined) { + return `${held.decision} rule` + } + + return held.decision === 'deny' ? 'refusal' : `${held.hook} hook's ask` +} diff --git a/mods/sec-default/hooks/held-verdict/held-notice.ts b/mods/sec-default/hooks/held-verdict/held-notice.ts index deb65f4e72..018c98ec6a 100644 --- a/mods/sec-default/hooks/held-verdict/held-notice.ts +++ b/mods/sec-default/hooks/held-verdict/held-notice.ts @@ -1,15 +1,32 @@ +import type { EventResult } from 'claude-code' + +import { heldKind } from './held-kind.js' + /** - * What a person reads, once for each plugin in a session, when a plugin they - * installed answered allow or ask over a deny rule in their settings. + * What a person reads, once for each plugin and kind in a session, when a + * plugin they installed answered more permissively than what holds over it. * * It names the option an administrator sets to let such plugins override. * * @param plugin the plugin's name, or its batch's, as the trace names it * @param tool the tool the call named - * @param rule the deny rule that decided, as written + * @param held the verdict that holds, naming the rule behind it if one is * @returns the line */ -export const heldNotice = (plugin: string, tool: string, rule: string) => - `${plugin} tried to lift a deny rule in your settings from a ${tool} ` + - `call (${rule}); the deny rule holds over the plugins you install ` + - '(allowModsToOverrideDenyRules)' +export function heldNotice( + plugin: string, + tool: string, + held: EventResult<'tool.check'>, +) { + const kind = heldKind(held) + const article = kind.startsWith('a') ? 'an' : 'a' + const lifted = + held.rule === undefined + ? `${article} ${kind} from a ${tool} call` + : `${article} ${kind} in your settings from a ${tool} call (${held.rule})` + + return ( + `${plugin} tried to lift ${lifted}; the ${kind} holds over the plugins ` + + 'you install (allowModsToOverrideDenyRules)' + ) +} diff --git a/mods/sec-default/hooks/held-verdict/index.ts b/mods/sec-default/hooks/held-verdict/index.ts index b6587e2458..5727e8f7d2 100644 --- a/mods/sec-default/hooks/held-verdict/index.ts +++ b/mods/sec-default/hooks/held-verdict/index.ts @@ -1,4 +1,5 @@ export * from './caught-answer.js' +export * from './held-kind.js' export * from './held-notice.js' export * from './verdicts' diff --git a/mods/sec-default/hooks/held-verdict/verdicts/holds-over.ts b/mods/sec-default/hooks/held-verdict/verdicts/holds-over.ts new file mode 100644 index 0000000000..da6f669e6b --- /dev/null +++ b/mods/sec-default/hooks/held-verdict/verdicts/holds-over.ts @@ -0,0 +1,24 @@ +import type { EventResult } from 'claude-code' + +import Ranking from './ranking' + +/** + * Whether the verdict reached past the user tier holds over the chain's + * answer: it is the stricter, and a deny, or an ask a rule or a hook decided. + * + * Any rule or classic hook counts, whoever configured it: a verdict names the + * rule and the hook's event, never their source. A deny holds unnamed: it may + * stand before a rule's or a hook's ask. The mode's own ask does not hold. + * + * @param held what the run past the user tier settled on + * @param answer what the whole chain settled on + * @returns true when the plugins a person installs may not have loosened it + */ +export const holdsOver = ( + held: EventResult<'tool.check'>, + answer: EventResult<'tool.check'>, +) => + Ranking.isLooser(answer, held) && + (held.decision === 'deny' || + held.rule !== undefined || + held.hook !== undefined) diff --git a/mods/sec-default/hooks/held-verdict/verdicts/index.ts b/mods/sec-default/hooks/held-verdict/verdicts/index.ts index 648e4d6885..ea59447b8f 100644 --- a/mods/sec-default/hooks/held-verdict/verdicts/index.ts +++ b/mods/sec-default/hooks/held-verdict/verdicts/index.ts @@ -1,7 +1,6 @@ -export * from './is-rule-deny.js' +export * from './holds-over.js' export * from './loosened-by-users.js' export * from './ranking' -export * from './types' export * from './unchecked-deny.js' export * as default from '.' diff --git a/mods/sec-default/hooks/held-verdict/verdicts/is-rule-deny.ts b/mods/sec-default/hooks/held-verdict/verdicts/is-rule-deny.ts deleted file mode 100644 index 6e99a46841..0000000000 --- a/mods/sec-default/hooks/held-verdict/verdicts/is-rule-deny.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { EventResult } from 'claude-code' - -import type { RuleDeny } from './types' - -/** - * Whether a `tool.check` verdict is a deny that a settings rule decided: the - * one verdict the plugins a person installs may not loosen. - * - * Any deny rule counts, whatever settings file it came from: a verdict - * carries the rule as written and never where it was read from. - * - * @param verdict what a run of the chain settled on - * @returns true for a deny that names the rule behind it - */ -export const isRuleDeny = ( - verdict: EventResult<'tool.check'>, -): verdict is RuleDeny => - verdict.decision === 'deny' && verdict.rule !== undefined diff --git a/mods/sec-default/hooks/held-verdict/verdicts/types/index.ts b/mods/sec-default/hooks/held-verdict/verdicts/types/index.ts deleted file mode 100644 index 5a37246438..0000000000 --- a/mods/sec-default/hooks/held-verdict/verdicts/types/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export type * from './rule-deny.js' - -export * as default from '.' diff --git a/mods/sec-default/hooks/held-verdict/verdicts/types/rule-deny.ts b/mods/sec-default/hooks/held-verdict/verdicts/types/rule-deny.ts deleted file mode 100644 index 42bbd44f6b..0000000000 --- a/mods/sec-default/hooks/held-verdict/verdicts/types/rule-deny.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { EventResult } from 'claude-code' - -/** - * A `tool.check` deny that names the settings rule behind it. - */ -export type RuleDeny = EventResult<'tool.check'> & { - readonly decision: 'deny' - readonly rule: string -} diff --git a/mods/sec-default/hooks/held-verdict/verdicts/unchecked-deny.ts b/mods/sec-default/hooks/held-verdict/verdicts/unchecked-deny.ts index 3a5a30f9fa..da64aa57d7 100644 --- a/mods/sec-default/hooks/held-verdict/verdicts/unchecked-deny.ts +++ b/mods/sec-default/hooks/held-verdict/verdicts/unchecked-deny.ts @@ -2,11 +2,11 @@ import type { EventResult } from 'claude-code' /** * What the failure handler answers when a verdict was loosened, or never - * reached, and no deny rule check vouches for it: absent counts as deny. + * reached, and no check of the rules and hooks vouches for it: a deny. */ export const UNCHECKED_DENY: EventResult<'tool.check'> = Object.freeze({ decision: 'deny', reason: - 'the deny rules in your settings could not be checked for this call, ' + - 'so it is refused', + 'the rules and hooks in your settings could not be checked for this ' + + 'call, so it is refused', }) diff --git a/mods/sec-default/hooks/hooks.json b/mods/sec-default/hooks/hooks.json index 4a70fc2dcf..928b8f0a61 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 deny, and an ask a settings rule or a classic hook decided, over a user-tier allow or ask on tool.check, keeps the organization's own log lines and the variables its managed env sets out of the user tier's reach, and refuses a user-tier hooks module at plugin.register while managed settings set its allowManagedModsOnly option", "modules": ["./register.ts"] } diff --git a/mods/sec-default/hooks/index.ts b/mods/sec-default/hooks/index.ts index bd91ec39d2..3ee2ed7dab 100644 --- a/mods/sec-default/hooks/index.ts +++ b/mods/sec-default/hooks/index.ts @@ -2,6 +2,7 @@ export * from './admission-failure' export * from './held-verdict' export * from './managed-mods-only-refusal' export * from './past-users' +export * from './pinned-variable-refusal' export * from './policy' export * from './register.js' export * from './tool-register-refusal' diff --git a/mods/sec-default/hooks/pinned-variable-refusal/index.ts b/mods/sec-default/hooks/pinned-variable-refusal/index.ts new file mode 100644 index 0000000000..e902a12425 --- /dev/null +++ b/mods/sec-default/hooks/pinned-variable-refusal/index.ts @@ -0,0 +1,3 @@ +export * from './pinned-variable-refusal.js' + +export * as default from '.' diff --git a/mods/sec-default/hooks/pinned-variable-refusal/pinned-variable-refusal.ts b/mods/sec-default/hooks/pinned-variable-refusal/pinned-variable-refusal.ts new file mode 100644 index 0000000000..9ad794b8a4 --- /dev/null +++ b/mods/sec-default/hooks/pinned-variable-refusal/pinned-variable-refusal.ts @@ -0,0 +1,12 @@ +/** + * Why a plugin a person installed may not set or unset an environment + * variable their organization pins: the setting, then the rule. + * + * Said too when the policy cannot be read and every variable counts as + * pinned, so it does not say who set this one. + * + * @param name the variable, as the plugin named it + * @returns the refusal + */ +export const pinnedVariableRefusal = (name: string) => + `env (managed): ${name} is not for plugins outside policy to change` diff --git a/mods/sec-default/hooks/policy/deny-rules-hold.ts b/mods/sec-default/hooks/policy/deny-rules-hold.ts index 9667a09b75..81043e8169 100644 --- a/mods/sec-default/hooks/policy/deny-rules-hold.ts +++ b/mods/sec-default/hooks/policy/deny-rules-hold.ts @@ -6,8 +6,9 @@ import { ownOption } from './own-option' * Whether deny rules hold over the plugins a person installs: they do unless * managed policy sets `allowModsToOverrideDenyRules` to the literal `true`. * - * A value mistyped (`"true"`, `1`) loosens nothing. Only the policy source - * is handed in, so a person's settings never reach this. + * Ask rules and classic hooks' answers hold or not with them. A value mistyped + * (`"true"`, `1`) loosens nothing. Only the policy source is handed in, so a + * person's settings never reach this. * * @param policy the managed settings, as `$.settings.read` answers them * @returns false only when the organization let a person's plugins override diff --git a/mods/sec-default/hooks/policy/index.ts b/mods/sec-default/hooks/policy/index.ts index 8e6f5a8ce4..318e5687b6 100644 --- a/mods/sec-default/hooks/policy/index.ts +++ b/mods/sec-default/hooks/policy/index.ts @@ -3,6 +3,7 @@ export * from './decided-by-policy.js' export * from './deny-rules-hold.js' export * from './has-mcp-allowlist.js' export * from './is-managed-mods-only.js' +export * from './is-pinned-variable.js' export * from './managed-tools-restored' export * from './own-option' export * from './policy-memo-ms.js' diff --git a/mods/sec-default/hooks/policy/is-pinned-variable.ts b/mods/sec-default/hooks/policy/is-pinned-variable.ts new file mode 100644 index 0000000000..e94a764daa --- /dev/null +++ b/mods/sec-default/hooks/policy/is-pinned-variable.ts @@ -0,0 +1,14 @@ +import type { Settings } from 'claude-code' + +/** + * Whether managed policy pins an environment variable: its `env` sets the + * name, in any case (some systems read `Path` and `PATH` as one variable). + * + * @param policy the managed settings, as `$.settings.read` answers them + * @param name the variable a plugin would set or unset + * @returns true when the organization set it + */ +export const isPinnedVariable = (policy: Settings, name: string) => + Object.keys(policy.env ?? {}).some( + pinned => pinned.toUpperCase() === name.toUpperCase(), + ) diff --git a/mods/sec-default/hooks/register.ts b/mods/sec-default/hooks/register.ts index 8b8c8000cf..15a8dce781 100644 --- a/mods/sec-default/hooks/register.ts +++ b/mods/sec-default/hooks/register.ts @@ -4,6 +4,7 @@ import { admissionFailure } from './admission-failure' import HeldVerdict from './held-verdict' import { managedModsOnlyRefusal } from './managed-mods-only-refusal' import { pastUsers } from './past-users' +import { pinnedVariableRefusal } from './pinned-variable-refusal' import Policy from './policy' import { TOOL_REGISTER_REFUSAL } from './tool-register-refusal' @@ -82,13 +83,15 @@ export function register(on: On) { const held = await next.to(e, 'append') - if (!HeldVerdict.isRuleDeny(held)) { + if (!HeldVerdict.holdsOver(held, answer)) { return answer } - for (const mod of mods.filter(name => !told.has(name))) { - told.add(mod) - $.ui.log(HeldVerdict.heldNotice(mod, e.tool, held.rule)) + const kind = HeldVerdict.heldKind(held) + + for (const mod of mods.filter(name => !told.has(`${name} ${kind}`))) { + told.add(`${mod} ${kind}`) + $.ui.log(HeldVerdict.heldNotice(mod, e.tool, held)) } return held @@ -103,6 +106,40 @@ export function register(on: On) { return shouldVouch ? HeldVerdict.caughtAnswer(last, next.trace) : last }) + on('ui.log', ($, e, next) => { + const isOrgs = + next.origin.tier === 'prepend' || next.origin.tier === 'append' + + return isOrgs ? next.to(e, 'append') : next(e) + }) + + on('env.set', async ($, e, next) => { + const isPinned = await Policy.decidedByPolicy( + readPolicy(() => $.settings.read(Policy.SOURCE)), + policy => Policy.isPinnedVariable(policy, e.name), + ) + + if (!isPinned) { + return next(e) + } + + const isTheirs = next.origin.tier === 'user' + + return isTheirs + ? { deny: pinnedVariableRefusal(e.name) } + : next.to(e, 'append') + }).catch(($, e, next) => { + if (next.called) { + return next(e) + } + + const isTheirs = next.origin.tier === 'user' + + return isTheirs + ? { deny: pinnedVariableRefusal(e.name) } + : next.to(e, 'append') + }) + on('plugin.register', { tier: 'user' }, async ($, e, next) => Policy.isManagedModsOnly(await $.settings.read(Policy.SOURCE)) ? { refuse: managedModsOnlyRefusal(e.name) } diff --git a/mods/sec-default/tests/fixtures/index.ts b/mods/sec-default/tests/fixtures/index.ts index 4d7071d65b..fc91393004 100644 --- a/mods/sec-default/tests/fixtures/index.ts +++ b/mods/sec-default/tests/fixtures/index.ts @@ -10,6 +10,8 @@ export * from './fullscreen.js' export * from './heading.js' export * from './listing.js' export * from './logged.js' +export * from './logging.js' +export * from './lower-case-pinning-policy.js' export * from './managed-mods-only.js' export * from './managed-policy.js' export * from './marking.js' @@ -18,6 +20,7 @@ export * from './mods-policy-of.js' export * from './no-allowlist.js' export * from './odd-providers.js' export * from './org-providers.js' +export * from './pinning-policy.js' export * from './policy-by-source.js' export * from './policy-command.js' export * from './policy-reads.js' @@ -26,9 +29,13 @@ export * from './reading.js' export * from './registered-tool-of.js' export * from './registering.js' export * from './relabeling.js' +export * from './revaluing.js' export * from './rewording.js' export * from './server-policy.js' export * from './session.js' +export * from './setting-lower-case.js' +export * from './setting-other.js' +export * from './setting-proxy.js' export * from './signing.js' export * from './stripping.js' export * from './subjects-echoed.js' @@ -37,6 +44,8 @@ export * from './tool-described.js' export * from './tools.js' export * from './tools-command.js' export * from './tools-registered.js' +export * from './unsetting-proxy.js' export * from './user-reachable-providers.js' +export * from './variables-set.js' export * as default from '.' diff --git a/mods/sec-default/tests/fixtures/logging.ts b/mods/sec-default/tests/fixtures/logging.ts new file mode 100644 index 0000000000..cf91c77eb7 --- /dev/null +++ b/mods/sec-default/tests/fixtures/logging.ts @@ -0,0 +1,21 @@ +import type { Plugin } from 'claude-code/testing' + +/** + * A plugin that logs `hello` when the session starts, in the tier given: 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('session.start', async ($, e, next) => { + await $.ui.log('hello') + + return next(e) + }) + }, +}) diff --git a/mods/sec-default/tests/fixtures/lower-case-pinning-policy.ts b/mods/sec-default/tests/fixtures/lower-case-pinning-policy.ts new file mode 100644 index 0000000000..d9c5cfa09b --- /dev/null +++ b/mods/sec-default/tests/fixtures/lower-case-pinning-policy.ts @@ -0,0 +1,8 @@ +import type { Settings } from 'claude-code' + +/** + * Managed settings that pin `corp_proxy`: `CORP_PROXY` in another case. + */ +export const LOWER_CASE_PINNING_POLICY: Settings = { + env: { corp_proxy: 'http://proxy.corp.example.com' }, +} diff --git a/mods/sec-default/tests/fixtures/pinning-policy.ts b/mods/sec-default/tests/fixtures/pinning-policy.ts new file mode 100644 index 0000000000..47f15204b0 --- /dev/null +++ b/mods/sec-default/tests/fixtures/pinning-policy.ts @@ -0,0 +1,8 @@ +import type { Settings } from 'claude-code' + +/** + * Managed settings that pin one environment variable, `CORP_PROXY`. + */ +export const PINNING_POLICY: Settings = { + env: { CORP_PROXY: 'http://proxy.corp.example.com' }, +} diff --git a/mods/sec-default/tests/fixtures/revaluing.ts b/mods/sec-default/tests/fixtures/revaluing.ts new file mode 100644 index 0000000000..9652146c29 --- /dev/null +++ b/mods/sec-default/tests/fixtures/revaluing.ts @@ -0,0 +1,12 @@ +import type { Plugin } from 'claude-code/testing' + +/** + * A plugin the person installed that rewrites the value of every variable + * any plugin sets to `theirs`. + */ +export const revaluing: Plugin = { + name: 'revaluing', + register(on) { + on('env.set', ($, e, next) => next({ ...e, value: 'theirs' })) + }, +} diff --git a/mods/sec-default/tests/fixtures/setting-lower-case.ts b/mods/sec-default/tests/fixtures/setting-lower-case.ts new file mode 100644 index 0000000000..ef2a1e09a6 --- /dev/null +++ b/mods/sec-default/tests/fixtures/setting-lower-case.ts @@ -0,0 +1,16 @@ +import type { Plugin } from 'claude-code/testing' + +/** + * A plugin the person installed that sets `corp_proxy`, `CORP_PROXY` in + * another case, when the session starts; a refusal is kept from the session. + */ +export const settingLowerCase: Plugin = { + name: 'lower', + register(on) { + on('session.start', async ($, e, next) => { + await $.env.set('corp_proxy', 'mine').catch(() => undefined) + + return next(e) + }) + }, +} diff --git a/mods/sec-default/tests/fixtures/setting-other.ts b/mods/sec-default/tests/fixtures/setting-other.ts new file mode 100644 index 0000000000..32d1314ee5 --- /dev/null +++ b/mods/sec-default/tests/fixtures/setting-other.ts @@ -0,0 +1,21 @@ +import type { Plugin } from 'claude-code/testing' + +/** + * A plugin that sets `OTHER` to `mine` when the session starts, in the tier + * given: the person's own by default. A refusal is kept from the session. + * + * @param name the plugin's name + * @param tier the tier it loads in + * @returns the plugin + */ +export const settingOther = (name: string, tier?: Plugin['tier']): Plugin => ({ + name, + tier, + register(on) { + on('session.start', async ($, e, next) => { + await $.env.set('OTHER', 'mine').catch(() => undefined) + + return next(e) + }) + }, +}) diff --git a/mods/sec-default/tests/fixtures/setting-proxy.ts b/mods/sec-default/tests/fixtures/setting-proxy.ts new file mode 100644 index 0000000000..2bd18e7ecd --- /dev/null +++ b/mods/sec-default/tests/fixtures/setting-proxy.ts @@ -0,0 +1,23 @@ +import type { Plugin } from 'claude-code/testing' + +/** + * A plugin that sets `CORP_PROXY` to `mine` when the session starts, in the + * tier given: the person's own by default. It logs a refusal and goes on. + * + * @param name the plugin's name + * @param tier the tier it loads in + * @returns the plugin + */ +export const settingProxy = (name: string, tier?: Plugin['tier']): Plugin => ({ + name, + tier, + register(on) { + on('session.start', async ($, e, next) => { + await $.env + .set('CORP_PROXY', 'mine') + .catch((error: unknown) => $.ui.log(String(error))) + + return next(e) + }) + }, +}) diff --git a/mods/sec-default/tests/fixtures/tool-check/checks-answered-in-turn.ts b/mods/sec-default/tests/fixtures/tool-check/checks-answered-in-turn.ts new file mode 100644 index 0000000000..5d82211f9a --- /dev/null +++ b/mods/sec-default/tests/fixtures/tool-check/checks-answered-in-turn.ts @@ -0,0 +1,24 @@ +import type { EventResult, On } from 'claude-code' + +import { ASKED } from './asked.js' + +/** + * Answers every `tool.check` beneath the plugins with one verdict of a list, + * the first until the answer is called, then the next; past the last, ASKED. + * + * @param on the test's `on` + * @param verdicts what the engine's evaluation decides, call after call + * @returns `() => void`: from then on the next verdict answers + */ +export function checksAnsweredInTurn( + on: On, + verdicts: readonly EventResult<'tool.check'>[], +) { + let at = 0 + + on('tool.check', () => verdicts[at] ?? ASKED) + + return () => { + at += 1 + } +} diff --git a/mods/sec-default/tests/fixtures/tool-check/hook-ask.ts b/mods/sec-default/tests/fixtures/tool-check/hook-ask.ts new file mode 100644 index 0000000000..55056d4a1f --- /dev/null +++ b/mods/sec-default/tests/fixtures/tool-check/hook-ask.ts @@ -0,0 +1,11 @@ +import type { EventResult } from 'claude-code' + +/** + * The engine's verdict when a PreToolUse hook asked for a person: the ask, + * the hook's sentence, and the hook's event. + */ +export const HOOK_ASK: EventResult<'tool.check'> = { + decision: 'ask', + reason: 'a person approves every echo', + hook: 'PreToolUse', +} diff --git a/mods/sec-default/tests/fixtures/tool-check/hook-deny.ts b/mods/sec-default/tests/fixtures/tool-check/hook-deny.ts new file mode 100644 index 0000000000..dd76852f4d --- /dev/null +++ b/mods/sec-default/tests/fixtures/tool-check/hook-deny.ts @@ -0,0 +1,11 @@ +import type { EventResult } from 'claude-code' + +/** + * The engine's verdict when a PreToolUse hook refused the call: the deny, + * the hook's sentence, and the hook's event. + */ +export const HOOK_DENY: EventResult<'tool.check'> = { + decision: 'deny', + reason: 'no echo here', + hook: 'PreToolUse', +} diff --git a/mods/sec-default/tests/fixtures/tool-check/index.ts b/mods/sec-default/tests/fixtures/tool-check/index.ts index ca3145deaf..f516018066 100644 --- a/mods/sec-default/tests/fixtures/tool-check/index.ts +++ b/mods/sec-default/tests/fixtures/tool-check/index.ts @@ -5,13 +5,19 @@ export * from './asking.js' export * from './blind-allowing.js' export * from './checked.js' export * from './checks-answered.js' +export * from './checks-answered-in-turn.js' export * from './forging.js' +export * from './hook-ask.js' +export * from './hook-deny.js' export * from './link-of.js' export * from './listening.js' +export * from './muting.js' export * from './override-policy-of.js' export * from './plain-deny.js' export * from './rewriting.js' +export * from './rule-ask.js' export * from './rule-deny.js' +export * from './sneaking.js' export * from './tightening.js' export * as default from '.' diff --git a/mods/sec-default/tests/fixtures/tool-check/muting.ts b/mods/sec-default/tests/fixtures/tool-check/muting.ts new file mode 100644 index 0000000000..cde69c8a08 --- /dev/null +++ b/mods/sec-default/tests/fixtures/tool-check/muting.ts @@ -0,0 +1,13 @@ +import type { Plugin } from 'claude-code/testing' + +/** + * A plugin the person installed that allows every `tool.check` and drops + * every line any plugin logs. + */ +export const muting: Plugin = { + name: 'muting', + register(on) { + on('tool.check', () => ({ decision: 'allow' })) + on('ui.log', () => ({ value: undefined })) + }, +} diff --git a/mods/sec-default/tests/fixtures/tool-check/plain-deny.ts b/mods/sec-default/tests/fixtures/tool-check/plain-deny.ts index 4a990ea39c..fa807c40e2 100644 --- a/mods/sec-default/tests/fixtures/tool-check/plain-deny.ts +++ b/mods/sec-default/tests/fixtures/tool-check/plain-deny.ts @@ -1,10 +1,10 @@ import type { EventResult } from 'claude-code' /** - * A deny that no settings rule decided (a settings hook's, a tool's own - * check): it names no rule. + * A deny that neither a settings rule nor a classic hook decided (a tool's + * own check, a setting that is no rule, a plugin's answer): it names neither. */ export const PLAIN_DENY: EventResult<'tool.check'> = { decision: 'deny', - reason: 'a PreToolUse hook refused it', + reason: 'echo is refused here', } diff --git a/mods/sec-default/tests/fixtures/tool-check/rule-ask.ts b/mods/sec-default/tests/fixtures/tool-check/rule-ask.ts new file mode 100644 index 0000000000..d9fb9a14b8 --- /dev/null +++ b/mods/sec-default/tests/fixtures/tool-check/rule-ask.ts @@ -0,0 +1,11 @@ +import type { EventResult } from 'claude-code' + +/** + * The engine's verdict when an ask rule in some settings file matched the + * call: the ask, its sentence, and the rule as written. + */ +export const RULE_ASK: EventResult<'tool.check'> = { + decision: 'ask', + reason: 'Claude requested permissions to use Bash.', + rule: 'Bash(echo *)', +} diff --git a/mods/sec-default/tests/fixtures/tool-check/sneaking.ts b/mods/sec-default/tests/fixtures/tool-check/sneaking.ts new file mode 100644 index 0000000000..5e15c92b1f --- /dev/null +++ b/mods/sec-default/tests/fixtures/tool-check/sneaking.ts @@ -0,0 +1,25 @@ +import type { Plugin } from 'claude-code/testing' + +/** + * A plugin the person installed that allows every `tool.check` and, from + * inside each `$.ui.log` it hears, asks `tool.check` again and logs the answer. + * + * A line this plugin's own hook logs is one it does not hear. + */ +export const sneaking: Plugin = { + name: 'sneaking', + register(on) { + on('tool.check', () => ({ decision: 'allow' })) + + on('ui.log', async ($, e, next) => { + const nested = await $.tool.check({ + tool: 'Bash', + input: { command: 'echo x' }, + }) + + await $.ui.log(`nested: ${nested.decision}`) + + return next(e) + }) + }, +} diff --git a/mods/sec-default/tests/fixtures/tool-check/tightening.ts b/mods/sec-default/tests/fixtures/tool-check/tightening.ts index 41532abe5e..83dc126552 100644 --- a/mods/sec-default/tests/fixtures/tool-check/tightening.ts +++ b/mods/sec-default/tests/fixtures/tool-check/tightening.ts @@ -10,7 +10,7 @@ export const tightening: Plugin = { on('tool.check', async ($, e, next) => { await next(e) - return { decision: 'deny', reason: 'a PreToolUse hook refused it' } + return { decision: 'deny', reason: 'echo is refused here' } }) }, } diff --git a/mods/sec-default/tests/fixtures/unsetting-proxy.ts b/mods/sec-default/tests/fixtures/unsetting-proxy.ts new file mode 100644 index 0000000000..48cb7712db --- /dev/null +++ b/mods/sec-default/tests/fixtures/unsetting-proxy.ts @@ -0,0 +1,16 @@ +import type { Plugin } from 'claude-code/testing' + +/** + * A plugin the person installed that unsets `CORP_PROXY` when the session + * starts; a refusal is kept from the session. + */ +export const unsettingProxy: Plugin = { + name: 'unsetting', + register(on) { + on('session.start', async ($, e, next) => { + await $.env.set('CORP_PROXY', undefined).catch(() => undefined) + + return next(e) + }) + }, +} diff --git a/mods/sec-default/tests/fixtures/variables-set.ts b/mods/sec-default/tests/fixtures/variables-set.ts new file mode 100644 index 0000000000..22a9cfa183 --- /dev/null +++ b/mods/sec-default/tests/fixtures/variables-set.ts @@ -0,0 +1,22 @@ +import type { On } from 'claude-code' + +/** + * A session starting, where each variable a plugin sets reaches the engine + * and is kept as ` =`. + * + * @param on the test's `on` + * @returns what was set, in order + */ +export function variablesSet(on: On) { + const set: string[] = [] + + on('session.start', ($, e) => ({ cwd: e.cwd })) + + on('env.set', ($, e, next) => { + set.push(`${next.origin.plugin} ${e.name}=${String(e.value)}`) + + return { value: undefined } + }) + + return set +} diff --git a/mods/sec-default/tests/held-verdict.test.ts b/mods/sec-default/tests/held-verdict.test.ts index d8a047ee36..fd5516f448 100644 --- a/mods/sec-default/tests/held-verdict.test.ts +++ b/mods/sec-default/tests/held-verdict.test.ts @@ -6,12 +6,82 @@ import Fixtures from './fixtures' tier('prepend') describe('held-verdict', () => { - test('a deny that names its rule is a rule deny; any other is not', () => { + test('over an allow: any deny, and an ask a rule or a hook decided', () => { expect( - [Fixtures.RULE_DENY, Fixtures.PLAIN_DENY, Fixtures.ASKED].map( - Hooks.isRuleDeny, + [ + Fixtures.RULE_DENY, + Fixtures.RULE_ASK, + Fixtures.HOOK_DENY, + Fixtures.HOOK_ASK, + Fixtures.PLAIN_DENY, + Fixtures.ASKED, + Fixtures.ALLOWED, + ].map(held => Hooks.holdsOver(held, Fixtures.ALLOWED)), + ).toEqual([true, true, true, true, true, false, false]) + }) + + test('over an ask only a deny is stricter; over a deny nothing is', () => { + expect( + [ + Fixtures.RULE_DENY, + Fixtures.RULE_ASK, + Fixtures.HOOK_DENY, + Fixtures.HOOK_ASK, + ].map(held => [ + Hooks.holdsOver(held, Fixtures.ASKED), + Hooks.holdsOver(held, Fixtures.PLAIN_DENY), + ]), + ).toEqual([ + [true, false], + [false, false], + [true, false], + [false, false], + ]) + }) + + test('a rule or hook the answer itself names makes nothing hold', () => { + expect( + Hooks.holdsOver(Fixtures.ASKED, { + ...Fixtures.ALLOWED, + rule: 'Bash(echo *)', + hook: 'PreToolUse', + }), + ).toBe(false) + }) + + test('the notice names the kind that held, and the rule when one did', () => { + expect( + [ + Fixtures.RULE_DENY, + Fixtures.RULE_ASK, + { ...Fixtures.RULE_ASK, ...Fixtures.HOOK_ASK }, + Fixtures.HOOK_ASK, + Fixtures.HOOK_DENY, + Fixtures.PLAIN_DENY, + ].map(held => Hooks.heldNotice('easy', 'Bash', held)), + ).toEqual( + [ + 'a deny rule in your settings from a Bash call (Bash(echo *)); ' + + 'the deny rule', + 'an ask rule in your settings from a Bash call (Bash(echo *)); ' + + 'the ask rule', + 'an ask rule in your settings from a Bash call (Bash(echo *)); ' + + 'the ask rule', + "a PreToolUse hook's ask from a Bash call; the PreToolUse hook's ask", + 'a refusal from a Bash call; the refusal', + 'a refusal from a Bash call; the refusal', + ].map( + middle => + `easy tried to lift ${middle} holds over the plugins you install ` + + '(allowModsToOverrideDenyRules)', ), - ).toEqual([true, false, false]) + ) + }) + + test('the refusal of a pinned variable names the setting and it', () => { + expect(Hooks.pinnedVariableRefusal('CORP_PROXY')).toBe( + 'env (managed): CORP_PROXY is not for plugins outside policy to change', + ) }) test('a link of theirs is named when it answered looser than handed', () => { diff --git a/mods/sec-default/tests/policy/is-pinned-variable.test.ts b/mods/sec-default/tests/policy/is-pinned-variable.test.ts new file mode 100644 index 0000000000..dc3e02ba81 --- /dev/null +++ b/mods/sec-default/tests/policy/is-pinned-variable.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, test, tier } from 'claude-code/testing' + +import Policy from '../../hooks/policy' +import Fixtures from '../fixtures' + +tier('prepend') + +describe('is-pinned-variable', () => { + test('a name managed env sets is pinned, in any case; no other is', () => { + expect( + ['CORP_PROXY', 'corp_proxy', 'Corp_Proxy', 'CORP', 'CORP_PROXY_2'].map( + name => Policy.isPinnedVariable(Fixtures.PINNING_POLICY, name), + ), + ).toEqual([true, true, true, false, false]) + }) + + test('whichever case managed env itself spells it in', () => { + expect( + ['CORP_PROXY', 'corp_proxy', 'CORP'].map(name => + Policy.isPinnedVariable(Fixtures.LOWER_CASE_PINNING_POLICY, name), + ), + ).toEqual([true, true, false]) + }) + + test('settings with no env pin nothing', () => { + expect( + [Fixtures.MANAGED_POLICY, {}, { env: {} }].map(policy => + Policy.isPinnedVariable(policy, 'CORP_PROXY'), + ), + ).toEqual([false, false, false]) + }) +}) diff --git a/mods/sec-default/tests/register.test.ts b/mods/sec-default/tests/register.test.ts index f30648c8dc..c86d45b07b 100644 --- a/mods/sec-default/tests/register.test.ts +++ b/mods/sec-default/tests/register.test.ts @@ -507,7 +507,7 @@ describe('register', () => { expect(await $.tool.check(Fixtures.CHECKED)).toEqual(Fixtures.RULE_DENY) expect(lines).toEqual([ - `transcript: ${Hooks.heldNotice('easy', 'Bash', 'Bash(echo *)')}`, + `transcript: ${Hooks.heldNotice('easy', 'Bash', Fixtures.RULE_DENY)}`, ]) }, ) @@ -528,8 +528,8 @@ describe('register', () => { expect(await $.tool.check(Fixtures.CHECKED)).toEqual(Fixtures.RULE_DENY) expect(lines.toSorted()).toEqual([ - `transcript: ${Hooks.heldNotice('first', 'Bash', 'Bash(echo *)')}`, - `transcript: ${Hooks.heldNotice('second', 'Bash', 'Bash(echo *)')}`, + `transcript: ${Hooks.heldNotice('first', 'Bash', Fixtures.RULE_DENY)}`, + `transcript: ${Hooks.heldNotice('second', 'Bash', Fixtures.RULE_DENY)}`, ]) }, ) @@ -597,14 +597,131 @@ describe('register', () => { ) test( - 'an ask a plugin of the person allows, no deny rule behind it, stands', + 'an ask rule holds over an allow from a plugin the person installed', { plugins: [Fixtures.allowing('easy')] }, async ($, on) => { on('settings.read', () => ({ value: Fixtures.MANAGED_POLICY })) const lines = Fixtures.logged(on) - Fixtures.checksAnswered(on, Fixtures.ASKED) + Fixtures.checksAnswered(on, Fixtures.RULE_ASK) + + expect(await $.tool.check(Fixtures.CHECKED)).toEqual(Fixtures.RULE_ASK) + + expect(lines).toEqual([ + `transcript: ${Hooks.heldNotice('easy', 'Bash', Fixtures.RULE_ASK)}`, + ]) + }, + ) + + test( + "a classic hook's ask holds over an allow from a plugin of the person", + { plugins: [Fixtures.allowing('easy')] }, + async ($, on) => { + on('settings.read', () => ({ value: Fixtures.MANAGED_POLICY })) + + const lines = Fixtures.logged(on) + + Fixtures.checksAnswered(on, Fixtures.HOOK_ASK) + + expect(await $.tool.check(Fixtures.CHECKED)).toEqual(Fixtures.HOOK_ASK) + + expect(lines).toEqual([ + `transcript: ${Hooks.heldNotice('easy', 'Bash', Fixtures.HOOK_ASK)}`, + ]) + }, + ) + + test( + "a classic hook's deny holds over their allow", + { plugins: [Fixtures.allowing('easy')] }, + async ($, on) => { + on('settings.read', () => ({ value: Fixtures.MANAGED_POLICY })) + + const lines = Fixtures.logged(on) + + Fixtures.checksAnswered(on, Fixtures.HOOK_DENY) + + expect(await $.tool.check(Fixtures.CHECKED)).toEqual(Fixtures.HOOK_DENY) + + expect(lines).toEqual([ + `transcript: ${Hooks.heldNotice('easy', 'Bash', Fixtures.HOOK_DENY)}`, + ]) + }, + ) + + test( + "a classic hook's deny holds over their ask", + { plugins: [Fixtures.asking('easy')] }, + async ($, on) => { + on('settings.read', () => ({ value: Fixtures.MANAGED_POLICY })) + Fixtures.logged(on) + Fixtures.checksAnswered(on, Fixtures.HOOK_DENY) + + expect(await $.tool.check(Fixtures.CHECKED)).toEqual(Fixtures.HOOK_DENY) + }, + ) + + test( + 'an allow that never called next meets the ask rule all the same', + { plugins: [Fixtures.blindAllowing] }, + async ($, on) => { + on('settings.read', () => ({ value: Fixtures.MANAGED_POLICY })) + Fixtures.logged(on) + + const evaluations = Fixtures.checksAnswered(on, Fixtures.RULE_ASK) + + expect(await $.tool.check(Fixtures.CHECKED)).toEqual(Fixtures.RULE_ASK) + expect(evaluations()).toBe(1) + }, + ) + + test( + "an organization plugin's allow over an ask rule stands", + { + plugins: [ + Fixtures.allowing('suite', 'append'), + Fixtures.allowing('easy'), + ], + }, + async ($, on) => { + on('settings.read', () => ({ value: Fixtures.MANAGED_POLICY })) + + const lines = Fixtures.logged(on) + + Fixtures.checksAnswered(on, Fixtures.RULE_ASK) + + expect(await $.tool.check(Fixtures.CHECKED)).toEqual(Fixtures.ALLOWED) + expect(lines).toEqual([]) + }, + ) + + test( + 'an ask of theirs over an ask rule loosens nothing: 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.RULE_ASK) + + expect(await $.tool.check(Fixtures.CHECKED)).toEqual(Fixtures.ASKED) + expect({ lines, evaluations: evaluations() }).toEqual({ + lines: [], + evaluations: 1, + }) + }, + ) + + test( + 'the option that lets them override deny rules lets them override these', + { plugins: [Fixtures.allowing('easy')] }, + async ($, on) => { + on('settings.read', () => ({ value: Fixtures.overridePolicyOf(true) })) + + const lines = Fixtures.logged(on) + + Fixtures.checksAnswered(on, Fixtures.HOOK_ASK) expect(await $.tool.check(Fixtures.CHECKED)).toEqual(Fixtures.ALLOWED) expect(lines).toEqual([]) @@ -612,20 +729,136 @@ describe('register', () => { ) test( - 'a deny no rule decided is still theirs to answer over', + "the mode's own ask, no rule or hook behind it, is theirs to allow", { plugins: [Fixtures.allowing('easy')] }, async ($, on) => { on('settings.read', () => ({ value: Fixtures.MANAGED_POLICY })) const lines = Fixtures.logged(on) - Fixtures.checksAnswered(on, Fixtures.PLAIN_DENY) + Fixtures.checksAnswered(on, Fixtures.ASKED) expect(await $.tool.check(Fixtures.CHECKED)).toEqual(Fixtures.ALLOWED) expect(lines).toEqual([]) }, ) + test( + 'a deny holds whatever decided it: it may stand before a rule or a hook', + { plugins: [Fixtures.allowing('easy')] }, + async ($, on) => { + on('settings.read', () => ({ value: Fixtures.MANAGED_POLICY })) + + const lines = Fixtures.logged(on) + + Fixtures.checksAnswered(on, Fixtures.PLAIN_DENY) + + expect(await $.tool.check(Fixtures.CHECKED)).toEqual(Fixtures.PLAIN_DENY) + + expect(lines).toEqual([ + `transcript: ${Hooks.heldNotice('easy', 'Bash', Fixtures.PLAIN_DENY)}`, + ]) + }, + ) + + test( + 'the notice is no door: a plugin of theirs never runs inside this hook', + { plugins: [Fixtures.sneaking] }, + 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('sneaking', 'Bash', Fixtures.RULE_DENY), + ]) + }, + ) + + test( + 'nor is the notice theirs to drop', + { plugins: [Fixtures.muting] }, + async ($, on) => { + on('settings.read', () => ({ value: Fixtures.MANAGED_POLICY })) + + const lines = Fixtures.logged(on) + + Fixtures.checksAnswered(on, Fixtures.RULE_ASK) + + expect(await $.tool.check(Fixtures.CHECKED)).toEqual(Fixtures.RULE_ASK) + + expect(lines).toEqual([ + `transcript: ${Hooks.heldNotice('muting', 'Bash', Fixtures.RULE_ASK)}`, + ]) + }, + ) + + test( + "an organization's line is not theirs to drop; a line of their own is", + { + plugins: [ + Fixtures.muting, + Fixtures.logging('mine'), + Fixtures.logging('bundled', 'builtin'), + Fixtures.logging('suite', 'append'), + ], + }, + async ($, on) => { + on('settings.read', () => ({ value: Fixtures.MANAGED_POLICY })) + on('session.start', ($, e) => ({ cwd: e.cwd })) + + const heard: string[] = [] + + on('ui.log', ($, e, next) => { + heard.push(next.origin.plugin) + + return { value: undefined } + }) + + await $.session.start(Fixtures.SESSION) + + expect(heard).toEqual(['suite']) + }, + ) + + test( + 'a plugin is told once of each kind of thing that held over it', + { plugins: [Fixtures.allowing('easy')] }, + async ($, on) => { + on('settings.read', () => ({ value: Fixtures.MANAGED_POLICY })) + + const lines = Fixtures.logged(on) + const verdicts = [ + Fixtures.RULE_DENY, + Fixtures.RULE_ASK, + Fixtures.RULE_ASK, + Fixtures.HOOK_ASK, + Fixtures.PLAIN_DENY, + Fixtures.RULE_DENY, + ] + const turn = Fixtures.checksAnsweredInTurn(on, verdicts) + + for (const _ of verdicts) { + await $.tool.check(Fixtures.CHECKED) + turn() + } + + expect(lines).toEqual( + [ + Fixtures.RULE_DENY, + Fixtures.RULE_ASK, + Fixtures.HOOK_ASK, + Fixtures.PLAIN_DENY, + ].map(held => `transcript: ${Hooks.heldNotice('easy', 'Bash', held)}`), + ) + }, + ) + test( 'a plugin of the person that tightens is heard, with one evaluation', { plugins: [Fixtures.tightening] }, @@ -721,7 +954,8 @@ describe('register', () => { expect({ lines, evaluations: evaluations() }).toEqual({ lines: [ - `transcript: ${Hooks.heldNotice('blind', 'Bash', 'Bash(echo *)')}`, + 'transcript: ' + + Hooks.heldNotice('blind', 'Bash', Fixtures.RULE_DENY), ], evaluations: 1, }) @@ -741,7 +975,8 @@ describe('register', () => { expect(await $.tool.check(Fixtures.CHECKED)).toEqual(Fixtures.RULE_DENY) expect(lines).toEqual([ - `transcript: ${Hooks.heldNotice('forging', 'Bash', 'Bash(echo *)')}`, + 'transcript: ' + + Hooks.heldNotice('forging', 'Bash', Fixtures.RULE_DENY), ]) }, ) @@ -855,4 +1090,155 @@ describe('register', () => { expect(await $.tool.check(Fixtures.CHECKED)).toEqual(Hooks.UNCHECKED_DENY) }, ) + + test( + 'a variable managed settings pin is not for a plugin of the person to set', + { + plugins: [ + Fixtures.settingProxy('suite', 'prepend'), + Fixtures.settingProxy('mine'), + Fixtures.settingProxy('bundled', 'builtin'), + ], + }, + async ($, on) => { + on('settings.read', () => ({ value: Fixtures.PINNING_POLICY })) + + const lines = Fixtures.logged(on) + const set = Fixtures.variablesSet(on) + + await $.session.start(Fixtures.SESSION) + + expect(set).toEqual(['suite CORP_PROXY=mine', 'bundled CORP_PROXY=mine']) + expect(lines).toHaveLength(1) + expect(lines[0]).toContain(Hooks.pinnedVariableRefusal('CORP_PROXY')) + }, + ) + + test( + 'nor to unset', + { plugins: [Fixtures.unsettingProxy] }, + async ($, on) => { + on('settings.read', () => ({ value: Fixtures.PINNING_POLICY })) + + const set = Fixtures.variablesSet(on) + + await $.session.start(Fixtures.SESSION) + + expect(set).toEqual([]) + }, + ) + + test( + 'nor under another case, which some systems read as the same name', + { plugins: [Fixtures.settingLowerCase] }, + async ($, on) => { + on('settings.read', () => ({ value: Fixtures.PINNING_POLICY })) + Fixtures.logged(on) + + const set = Fixtures.variablesSet(on) + + await $.session.start(Fixtures.SESSION) + + expect(set).toEqual([]) + }, + ) + + test( + "nor to rewrite while an organization's plugin sets it", + { + plugins: [ + Fixtures.revaluing, + Fixtures.settingProxy('suite', 'append'), + Fixtures.settingOther('audit', 'append'), + ], + }, + async ($, on) => { + on('settings.read', () => ({ value: Fixtures.PINNING_POLICY })) + + const set = Fixtures.variablesSet(on) + + await $.session.start(Fixtures.SESSION) + + expect(set.toSorted()).toEqual([ + 'audit OTHER=theirs', + 'suite CORP_PROXY=mine', + ]) + }, + ) + + test( + 'the option that lets them override deny rules unpins nothing', + { plugins: [Fixtures.settingProxy('mine')] }, + async ($, on) => { + on('settings.read', () => ({ + value: { + ...Fixtures.overridePolicyOf(true), + ...Fixtures.PINNING_POLICY, + }, + })) + Fixtures.logged(on) + + const set = Fixtures.variablesSet(on) + + await $.session.start(Fixtures.SESSION) + + expect(set).toEqual([]) + }, + ) + + test( + 'a variable managed settings do not pin is theirs to set', + { plugins: [Fixtures.settingOther('mine')] }, + async ($, on) => { + on('settings.read', () => ({ value: Fixtures.PINNING_POLICY })) + + const set = Fixtures.variablesSet(on) + + await $.session.start(Fixtures.SESSION) + + expect(set).toEqual(['mine OTHER=mine']) + }, + ) + + test( + "only managed settings pin: a person's own env binds no plugin", + { plugins: [Fixtures.settingProxy('mine')] }, + async ($, on) => { + Fixtures.policyBySource( + on, + Fixtures.MANAGED_POLICY, + Fixtures.PINNING_POLICY, + ) + + const set = Fixtures.variablesSet(on) + + await $.session.start(Fixtures.SESSION) + + expect(set).toEqual(['mine CORP_PROXY=mine']) + }, + ) + + test( + 'with a policy that cannot be read they set no variable: fails closed', + { plugins: [Fixtures.settingOther('mine')] }, + async ($, on) => { + const stopPolicy = Fixtures.policyUntilStopped( + on, + Fixtures.MANAGED_POLICY, + 'managed settings unreadable', + ) + + Fixtures.logged(on) + + on('prompt.section', ($, e) => ({ text: e.text })) + + const set = Fixtures.variablesSet(on) + + await $.prompt.section(Fixtures.MEMORY) + stopPolicy() + await $.session.start(Fixtures.SESSION) + + expect(set).toEqual([]) + }, + ) }) diff --git a/mods/types/claude-code.d.ts b/mods/types/claude-code.d.ts index 757b350d8e..6c79c9ce3e 100644 --- a/mods/types/claude-code.d.ts +++ b/mods/types/claude-code.d.ts @@ -3695,7 +3695,7 @@ declare module 'claude-code' { */ 'tool.call': ToolCallResult; /** - * `{ decision, reason?, rule? }`. + * `{ decision, reason?, rule?, hook? }`. */ 'tool.check': ToolCheckResult; /** @@ -9943,7 +9943,7 @@ declare module 'claude-code' { /** * 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 classic hook 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 +9966,13 @@ declare module 'claude-code' { * Absent for a mode or a tool's own check. */ rule?: string; + /** + * The classic hook event that decided, or whose ask the verdict was reached + * under (`PreToolUse`), whoever configured the hook. + * + * Absent on a `$.tool.check` query, which runs no classic hook. + */ + hook?: string; }; /** From 8e7b0cc691cf9e65ca36c0f4c84974c000a87888 Mon Sep 17 00:00:00 2001 From: Alice Poteat Date: Fri, 2 Oct 2026 23:16:53 -0700 Subject: [PATCH 2/2] sec-default: what needs a newer engine waits for it The hold of a classic hook's ask is read off a field the published engine does not carry yet, so it leaves this change and follows by itself. What stays needs no new engine: any deny, an ask a settings rule decided, the organization's own log lines, and the variables managed env sets. --- mods/sec-default/README.md | 42 +++---- .../hooks/held-verdict/caught-answer.ts | 2 +- .../hooks/held-verdict/held-kind.ts | 14 +-- .../hooks/held-verdict/held-notice.ts | 12 +- .../hooks/held-verdict/verdicts/holds-over.ts | 12 +- .../held-verdict/verdicts/unchecked-deny.ts | 6 +- mods/sec-default/hooks/hooks.json | 2 +- .../hooks/policy/deny-rules-hold.ts | 2 +- .../fixtures/tool-check/allowing-bash.ts | 12 ++ .../fixtures/tool-check/allowing-read.ts | 12 ++ .../tests/fixtures/tool-check/checked-read.ts | 9 ++ .../tests/fixtures/tool-check/hook-ask.ts | 11 -- .../tests/fixtures/tool-check/hook-deny.ts | 11 -- .../tests/fixtures/tool-check/index.ts | 5 +- .../tests/fixtures/tool-check/plain-deny.ts | 4 +- mods/sec-default/tests/held-verdict.test.ts | 53 ++++----- .../tests/pinned-variable-refusal.test.ts | 13 +++ .../tests/policy/is-pinned-variable.test.ts | 6 + mods/sec-default/tests/register.test.ts | 105 ++++++++---------- mods/types/claude-code.d.ts | 11 +- 20 files changed, 167 insertions(+), 177 deletions(-) create mode 100644 mods/sec-default/tests/fixtures/tool-check/allowing-bash.ts create mode 100644 mods/sec-default/tests/fixtures/tool-check/allowing-read.ts create mode 100644 mods/sec-default/tests/fixtures/tool-check/checked-read.ts delete mode 100644 mods/sec-default/tests/fixtures/tool-check/hook-ask.ts delete mode 100644 mods/sec-default/tests/fixtures/tool-check/hook-deny.ts create mode 100644 mods/sec-default/tests/pinned-variable-refusal.test.ts diff --git a/mods/sec-default/README.md b/mods/sec-default/README.md index d9de60c569..7af735e32d 100644 --- a/mods/sec-default/README.md +++ b/mods/sec-default/README.md @@ -4,9 +4,10 @@ The security default for organizations. Function hooks give every plugin a say on every event, in chain order, and the plugins a person installs sit in the user tier, beneath the organization's prepend tier and above its append tier. Some of what an organization sets today (its classic hooks, -its managed CLAUDE.md and rules, its settings, its MCP allowlist, the deny -rules in force on its machines) was never within a person's reach before -function hooks; seated outermost, this plugin keeps exactly those out of the +its managed CLAUDE.md and rules, its settings and the environment they set, +its MCP allowlist, the permission rules and refusals in force on its machines) +was never within a person's reach before function hooks; seated outermost, +this plugin keeps exactly those, and the lines its own plugins log, out of the user tier's reach and adds no policy of its own. Everything else passes through untouched. @@ -32,7 +33,7 @@ settings it decides by. | `tool.describe`, `command.describe`, `agent.offer`, `agent.spawn` | When the subject's pinned `e.provider.tier` is `prepend` or `append` (a policy-installed plugin, the managed folder, a policy MCP server), continue past the user tier; a subject provided by `user`, `builtin` or `core` passes. | | `tool.register` | A caller in `prepend` or `append` continues past the user tier. A `user`-tier caller is refused by name while managed settings hold `allowedMcpServers` (set at all, empty included); otherwise it passes. | | `tool.list` | The tools of the organization's managed MCP servers are listed as the organization's tiers listed them; every other tool as the user tier left it. With no policy to read, or a refusal from either listing, the organization's listing stands whole. | -| `tool.check` | A deny, and an ask a settings rule or a classic hook decided, hold over the user tier: when a person's plugin loosened the verdict it was handed, the dispatch is run again past the user tier, and if that verdict is the stricter and is a deny, or names the rule or the hook behind it, it is the answer. See [Deny rules hold](#deny-rules-hold). Every other verdict passes as the chain left it. | +| `tool.check` | A deny, and an ask a settings rule decided, hold over the user tier: when a person's plugin loosened the verdict it was handed, the dispatch is run again past the user tier, and if that verdict is the stricter and is a deny, or names the rule behind it, it is the answer. See [Deny rules hold](#deny-rules-hold). Every other verdict passes as the chain left it. | | `ui.log` | A line a plugin in `prepend` or `append` logs, this one's included, continues past the user tier: no user hook rewrites or drops it, and none runs inside the hook that logged it. Every other line passes. | | `env.set` | A variable managed settings set in `env` (its name in any case) is pinned: setting or unsetting it, a `user`-tier caller is refused by name and any other caller continues past the user tier, so no user hook rewrites the value. A variable the organization does not set passes. With no policy to read, every variable counts as pinned. `allowModsToOverrideDenyRules` does not unpin. | | `plugin.register` | A hooks module in the `user` tier (one a person installed, named with `--plugin-dir`, or keeps in their mods folder) is refused while managed settings set this plugin's `allowManagedModsOnly` option; otherwise it passes. Modules in `prepend`, `append` and `builtin` are never asked about. | @@ -89,9 +90,9 @@ not loaded. Settings hooks, status lines and `/goal` are not touched by it. `allowModsToOverrideDenyRules`: the plugins a person installs may answer over a settings deny rule on `tool.check`, as they could before this plugin -held deny rules, and over every other deny, an ask rule or a classic hook's -ask with it: the one option covers all that holds there. Off unless it is the -literal `true`; an option that reads as unset leaves them holding. See +held deny rules, and over every other deny and an ask rule with it: the one +option covers all that holds there. Off unless it is the literal `true`; an +option that reads as unset leaves them holding. See [Deny rules hold](#deny-rules-hold). ## What it hooks @@ -118,8 +119,8 @@ do. On `tool.check` any hook may answer any verdict, so a plugin a person installs to stop the permission prompts (`() => ({ decision: "allow" })`) -would also lift a deny rule, an ask rule or a `PreToolUse` hook's ask, a -managed one included. Where this plugin is seated it does not: +would also lift a deny rule or an ask rule, a managed one included. Where +this plugin is seated it does not: - The hook first runs the chain as it is. If the answer is a deny, or no link that may hold a person's plugin answered more permissively than the @@ -138,18 +139,18 @@ managed one included. Where this plugin is seated it does not: changes nothing: the rules are evaluated in this run. The two runs differ by the user tier alone, so a verdict here that is stricter than the chain's answer is one the user tier loosened. A deny is returned in place of the - chain's answer whatever decided it; an ask, when it names what decided it: - a settings rule (`rule`) or a classic hook (`hook`). -- Any rule and any classic hook counts, whoever configured it: a verdict - carries the rule as written and the hook's event, never where either was - read from. A deny holds unnamed because it can stand in front of a rule's or - a hook's ask: lifted, the call would run with nobody asked. An ask that names - neither (the mode's own, a check of the engine's own) is not held. + chain's answer whatever decided it; an ask, when it names the settings rule + that decided it (`rule`). +- Any rule counts, whoever configured it: a verdict carries the rule as + written, never where it was read from. A deny holds unnamed because it can + stand in front of a rule's ask: lifted, the call would run with nobody + asked. An ask that names no rule (the mode's own, a settings hook's, a check + of the engine's own, an organization's plugin's) is not held. - An organization's plugin (prepend or append) or a built-in that allows over a deny rule takes part in both runs, so its answer stands (a prepended one that loosens is what brings the second run about, so its hooks run twice on such a call). The mode's own ask - that a person's plugin turns into an allow, with no rule or hook behind it, + that a person's plugin turns into an allow, with no rule behind it, stands: that is what such a plugin is for. - `tool.check` pins the question (`tool`, `input`, `tool_use_id`), so no hook can have the rules evaluated on one command and another run; a rewrite @@ -158,9 +159,8 @@ managed one included. Where this plugin is seated it does not: transcript and the debug log: ` tried to lift a deny rule in your settings from a call (); the deny rule holds over the plugins you install (allowModsToOverrideDenyRules)`. The kinds are `a deny rule`, - `an ask rule`, `a hook's ask` and `a refusal` (a deny that names no - rule); the last two carry neither `in your settings` nor a rule. Plugins the - engine ran as one + `an ask rule` and `a refusal` (a deny that names no rule); the last carries + neither `in your settings` nor a rule. Plugins the engine ran as one batch are named together, as it names them (`audit+easy`). A plain `-p` run has it in the debug log alone; the call is still denied, or asked about, with its own message. The line goes past the user tier (the `ui.log` @@ -179,7 +179,7 @@ managed one included. Where this plugin is seated it does not: - If the hook itself fails, its `.catch` answers from the one run it can read: a deny stands; a verdict no plugin of the person's loosened stands; one they loosened, or a run that rejected, is refused, since the rules - and hooks were never consulted. + were never consulted. An organization that wants the plugins its people install to override deny rules, and all else that holds with them, says so in managed settings, under diff --git a/mods/sec-default/hooks/held-verdict/caught-answer.ts b/mods/sec-default/hooks/held-verdict/caught-answer.ts index 9f78154977..b1f1bf32d7 100644 --- a/mods/sec-default/hooks/held-verdict/caught-answer.ts +++ b/mods/sec-default/hooks/held-verdict/caught-answer.ts @@ -7,7 +7,7 @@ import Verdicts from './verdicts' * can read, the failed hook's last: that run's verdict, or a refusal. * * A deny stands, and so does a verdict no link that may hold a person's - * plugin loosened. A loosened one, or none at all, met no rule or hook. + * plugin loosened. A loosened one, or none at all, met no rule. * * @param last what that run settled on; undefined when it rejected * @param trace that run's `next.trace` diff --git a/mods/sec-default/hooks/held-verdict/held-kind.ts b/mods/sec-default/hooks/held-verdict/held-kind.ts index f622cc5735..aee59bfdf9 100644 --- a/mods/sec-default/hooks/held-verdict/held-kind.ts +++ b/mods/sec-default/hooks/held-verdict/held-kind.ts @@ -2,18 +2,10 @@ import type { EventResult } from 'claude-code' /** * What kind of thing held over a person's plugin, as the notice calls it: - * `deny rule`, `ask rule`, ` hook's ask`, or `refusal`. - * - * A deny that names no rule is a refusal even when it names a hook: the hook - * may only have asked, and something else refused. + * `deny rule`, `ask rule`, or `refusal`, a deny that names no rule. * * @param held the verdict that holds * @returns the kind */ -export function heldKind(held: EventResult<'tool.check'>) { - if (held.rule !== undefined) { - return `${held.decision} rule` - } - - return held.decision === 'deny' ? 'refusal' : `${held.hook} hook's ask` -} +export const heldKind = (held: EventResult<'tool.check'>) => + held.rule === undefined ? 'refusal' : `${held.decision} rule` diff --git a/mods/sec-default/hooks/held-verdict/held-notice.ts b/mods/sec-default/hooks/held-verdict/held-notice.ts index 018c98ec6a..a5609fdedf 100644 --- a/mods/sec-default/hooks/held-verdict/held-notice.ts +++ b/mods/sec-default/hooks/held-verdict/held-notice.ts @@ -19,14 +19,12 @@ export function heldNotice( held: EventResult<'tool.check'>, ) { const kind = heldKind(held) - const article = kind.startsWith('a') ? 'an' : 'a' - const lifted = - held.rule === undefined - ? `${article} ${kind} from a ${tool} call` - : `${article} ${kind} in your settings from a ${tool} call (${held.rule})` + const isRuled = held.rule !== undefined return ( - `${plugin} tried to lift ${lifted}; the ${kind} holds over the plugins ` + - 'you install (allowModsToOverrideDenyRules)' + `${plugin} tried to lift ${kind.startsWith('a') ? 'an' : 'a'} ${kind}` + + `${isRuled ? ' in your settings' : ''} from a ${tool} call` + + `${isRuled ? ` (${held.rule})` : ''}; the ${kind} holds over the ` + + 'plugins you install (allowModsToOverrideDenyRules)' ) } diff --git a/mods/sec-default/hooks/held-verdict/verdicts/holds-over.ts b/mods/sec-default/hooks/held-verdict/verdicts/holds-over.ts index da6f669e6b..5f7b03015e 100644 --- a/mods/sec-default/hooks/held-verdict/verdicts/holds-over.ts +++ b/mods/sec-default/hooks/held-verdict/verdicts/holds-over.ts @@ -4,11 +4,11 @@ import Ranking from './ranking' /** * Whether the verdict reached past the user tier holds over the chain's - * answer: it is the stricter, and a deny, or an ask a rule or a hook decided. + * answer: it is the stricter, and a deny, or an ask a settings rule decided. * - * Any rule or classic hook counts, whoever configured it: a verdict names the - * rule and the hook's event, never their source. A deny holds unnamed: it may - * stand before a rule's or a hook's ask. The mode's own ask does not hold. + * Any rule counts, whoever configured it: a verdict names the rule, never its + * source. A deny holds unnamed: it may stand before a rule's ask. The mode's + * own ask does not hold, nor a settings hook's. * * @param held what the run past the user tier settled on * @param answer what the whole chain settled on @@ -19,6 +19,4 @@ export const holdsOver = ( answer: EventResult<'tool.check'>, ) => Ranking.isLooser(answer, held) && - (held.decision === 'deny' || - held.rule !== undefined || - held.hook !== undefined) + (held.decision === 'deny' || held.rule !== undefined) diff --git a/mods/sec-default/hooks/held-verdict/verdicts/unchecked-deny.ts b/mods/sec-default/hooks/held-verdict/verdicts/unchecked-deny.ts index da64aa57d7..e6b5155634 100644 --- a/mods/sec-default/hooks/held-verdict/verdicts/unchecked-deny.ts +++ b/mods/sec-default/hooks/held-verdict/verdicts/unchecked-deny.ts @@ -2,11 +2,11 @@ import type { EventResult } from 'claude-code' /** * What the failure handler answers when a verdict was loosened, or never - * reached, and no check of the rules and hooks vouches for it: a deny. + * reached, and no check of the rules vouches for it: a deny. */ export const UNCHECKED_DENY: EventResult<'tool.check'> = Object.freeze({ decision: 'deny', reason: - 'the rules and hooks in your settings could not be checked for this ' + - 'call, so it is refused', + 'the rules in your settings could not be checked for this call, ' + + 'so it is refused', }) diff --git a/mods/sec-default/hooks/hooks.json b/mods/sec-default/hooks/hooks.json index 928b8f0a61..7513d106cf 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 deny, and an ask a settings rule or a classic hook decided, over a user-tier allow or ask on tool.check, keeps the organization's own log lines and the variables its managed env sets out of the user tier's reach, and refuses a user-tier hooks module at plugin.register while managed settings set its allowManagedModsOnly option", + "description": "Security default: from the outermost seat, continues past the user tier on the organization's classic hooks, prompt content, settings and subjects, refuses a user-tier tool.register under an MCP allowlist, restores the organization's tools in tool.list, holds a deny, and an ask a settings rule decided, over a user-tier allow or ask on tool.check, keeps the organization's own log lines and the variables its managed env sets out of the user tier's reach, and refuses a user-tier hooks module at plugin.register while managed settings set its allowManagedModsOnly option", "modules": ["./register.ts"] } diff --git a/mods/sec-default/hooks/policy/deny-rules-hold.ts b/mods/sec-default/hooks/policy/deny-rules-hold.ts index 81043e8169..8ad279405a 100644 --- a/mods/sec-default/hooks/policy/deny-rules-hold.ts +++ b/mods/sec-default/hooks/policy/deny-rules-hold.ts @@ -6,7 +6,7 @@ import { ownOption } from './own-option' * Whether deny rules hold over the plugins a person installs: they do unless * managed policy sets `allowModsToOverrideDenyRules` to the literal `true`. * - * Ask rules and classic hooks' answers hold or not with them. A value mistyped + * Every other deny, and ask rules, hold or not with them. A value mistyped * (`"true"`, `1`) loosens nothing. Only the policy source is handed in, so a * person's settings never reach this. * diff --git a/mods/sec-default/tests/fixtures/tool-check/allowing-bash.ts b/mods/sec-default/tests/fixtures/tool-check/allowing-bash.ts new file mode 100644 index 0000000000..7710601aa1 --- /dev/null +++ b/mods/sec-default/tests/fixtures/tool-check/allowing-bash.ts @@ -0,0 +1,12 @@ +import type { Plugin } from 'claude-code/testing' + +/** + * A plugin the person installed that allows every Bash call and has no say + * on another tool's. + */ +export const allowingBash: Plugin = { + name: 'basher', + register(on) { + on('tool.check', { tool: 'Bash' }, () => ({ decision: 'allow' })) + }, +} diff --git a/mods/sec-default/tests/fixtures/tool-check/allowing-read.ts b/mods/sec-default/tests/fixtures/tool-check/allowing-read.ts new file mode 100644 index 0000000000..7993a7bf41 --- /dev/null +++ b/mods/sec-default/tests/fixtures/tool-check/allowing-read.ts @@ -0,0 +1,12 @@ +import type { Plugin } from 'claude-code/testing' + +/** + * A plugin the person installed that allows every Read call and has no say + * on another tool's. + */ +export const allowingRead: Plugin = { + name: 'reader', + register(on) { + on('tool.check', { tool: 'Read' }, () => ({ decision: 'allow' })) + }, +} diff --git a/mods/sec-default/tests/fixtures/tool-check/checked-read.ts b/mods/sec-default/tests/fixtures/tool-check/checked-read.ts new file mode 100644 index 0000000000..9bf608b42a --- /dev/null +++ b/mods/sec-default/tests/fixtures/tool-check/checked-read.ts @@ -0,0 +1,9 @@ +import type { Args } from 'claude-code' + +/** + * Another question the engine puts to `tool.check`: may Read read `notes`. + */ +export const CHECKED_READ: Args<'tool.check'> = { + tool: 'Read', + input: { file_path: 'notes' }, +} diff --git a/mods/sec-default/tests/fixtures/tool-check/hook-ask.ts b/mods/sec-default/tests/fixtures/tool-check/hook-ask.ts deleted file mode 100644 index 55056d4a1f..0000000000 --- a/mods/sec-default/tests/fixtures/tool-check/hook-ask.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { EventResult } from 'claude-code' - -/** - * The engine's verdict when a PreToolUse hook asked for a person: the ask, - * the hook's sentence, and the hook's event. - */ -export const HOOK_ASK: EventResult<'tool.check'> = { - decision: 'ask', - reason: 'a person approves every echo', - hook: 'PreToolUse', -} diff --git a/mods/sec-default/tests/fixtures/tool-check/hook-deny.ts b/mods/sec-default/tests/fixtures/tool-check/hook-deny.ts deleted file mode 100644 index dd76852f4d..0000000000 --- a/mods/sec-default/tests/fixtures/tool-check/hook-deny.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { EventResult } from 'claude-code' - -/** - * The engine's verdict when a PreToolUse hook refused the call: the deny, - * the hook's sentence, and the hook's event. - */ -export const HOOK_DENY: EventResult<'tool.check'> = { - decision: 'deny', - reason: 'no echo here', - hook: 'PreToolUse', -} diff --git a/mods/sec-default/tests/fixtures/tool-check/index.ts b/mods/sec-default/tests/fixtures/tool-check/index.ts index f516018066..e8e12ca09f 100644 --- a/mods/sec-default/tests/fixtures/tool-check/index.ts +++ b/mods/sec-default/tests/fixtures/tool-check/index.ts @@ -1,14 +1,15 @@ export * from './allowed.js' export * from './allowing.js' +export * from './allowing-bash.js' +export * from './allowing-read.js' export * from './asked.js' export * from './asking.js' export * from './blind-allowing.js' export * from './checked.js' +export * from './checked-read.js' export * from './checks-answered.js' export * from './checks-answered-in-turn.js' export * from './forging.js' -export * from './hook-ask.js' -export * from './hook-deny.js' export * from './link-of.js' export * from './listening.js' export * from './muting.js' diff --git a/mods/sec-default/tests/fixtures/tool-check/plain-deny.ts b/mods/sec-default/tests/fixtures/tool-check/plain-deny.ts index fa807c40e2..bd54637f1b 100644 --- a/mods/sec-default/tests/fixtures/tool-check/plain-deny.ts +++ b/mods/sec-default/tests/fixtures/tool-check/plain-deny.ts @@ -1,8 +1,8 @@ import type { EventResult } from 'claude-code' /** - * A deny that neither a settings rule nor a classic hook decided (a tool's - * own check, a setting that is no rule, a plugin's answer): it names neither. + * A deny no settings rule decided (a tool's own check, a setting that is no + * rule, a plugin's answer): it names none. */ export const PLAIN_DENY: EventResult<'tool.check'> = { decision: 'deny', diff --git a/mods/sec-default/tests/held-verdict.test.ts b/mods/sec-default/tests/held-verdict.test.ts index fd5516f448..296495f3ee 100644 --- a/mods/sec-default/tests/held-verdict.test.ts +++ b/mods/sec-default/tests/held-verdict.test.ts @@ -6,69 +6,50 @@ import Fixtures from './fixtures' tier('prepend') describe('held-verdict', () => { - test('over an allow: any deny, and an ask a rule or a hook decided', () => { + test('over an allow: any deny, and an ask a settings rule decided', () => { expect( [ Fixtures.RULE_DENY, Fixtures.RULE_ASK, - Fixtures.HOOK_DENY, - Fixtures.HOOK_ASK, Fixtures.PLAIN_DENY, Fixtures.ASKED, Fixtures.ALLOWED, ].map(held => Hooks.holdsOver(held, Fixtures.ALLOWED)), - ).toEqual([true, true, true, true, true, false, false]) + ).toEqual([true, true, true, false, false]) }) test('over an ask only a deny is stricter; over a deny nothing is', () => { expect( - [ - Fixtures.RULE_DENY, - Fixtures.RULE_ASK, - Fixtures.HOOK_DENY, - Fixtures.HOOK_ASK, - ].map(held => [ + [Fixtures.RULE_DENY, Fixtures.RULE_ASK].map(held => [ Hooks.holdsOver(held, Fixtures.ASKED), Hooks.holdsOver(held, Fixtures.PLAIN_DENY), ]), ).toEqual([ [true, false], [false, false], - [true, false], - [false, false], ]) }) - test('a rule or hook the answer itself names makes nothing hold', () => { + test('a rule the answer itself names makes nothing hold', () => { expect( Hooks.holdsOver(Fixtures.ASKED, { ...Fixtures.ALLOWED, rule: 'Bash(echo *)', - hook: 'PreToolUse', }), ).toBe(false) }) test('the notice names the kind that held, and the rule when one did', () => { expect( - [ - Fixtures.RULE_DENY, - Fixtures.RULE_ASK, - { ...Fixtures.RULE_ASK, ...Fixtures.HOOK_ASK }, - Fixtures.HOOK_ASK, - Fixtures.HOOK_DENY, - Fixtures.PLAIN_DENY, - ].map(held => Hooks.heldNotice('easy', 'Bash', held)), + [Fixtures.RULE_DENY, Fixtures.RULE_ASK, Fixtures.PLAIN_DENY].map(held => + Hooks.heldNotice('easy', 'Bash', held), + ), ).toEqual( [ 'a deny rule in your settings from a Bash call (Bash(echo *)); ' + 'the deny rule', 'an ask rule in your settings from a Bash call (Bash(echo *)); ' + 'the ask rule', - 'an ask rule in your settings from a Bash call (Bash(echo *)); ' + - 'the ask rule', - "a PreToolUse hook's ask from a Bash call; the PreToolUse hook's ask", - 'a refusal from a Bash call; the refusal', 'a refusal from a Bash call; the refusal', ].map( middle => @@ -78,10 +59,22 @@ describe('held-verdict', () => { ) }) - test('the refusal of a pinned variable names the setting and it', () => { - expect(Hooks.pinnedVariableRefusal('CORP_PROXY')).toBe( - 'env (managed): CORP_PROXY is not for plugins outside policy to change', - ) + test('what no check vouches for is refused, and says why', () => { + expect(Hooks.UNCHECKED_DENY).toEqual({ + decision: 'deny', + reason: + 'the rules in your settings could not be checked for this call, ' + + 'so it is refused', + }) + }) + + test('an ask of theirs over a deny, caught, is no more vouched for', () => { + expect( + Hooks.caughtAnswer(Fixtures.ASKED, [ + Fixtures.linkOf('easy', 'user', Fixtures.ASKED), + Fixtures.linkOf('engine', 'core', Fixtures.RULE_DENY), + ]), + ).toEqual(Hooks.UNCHECKED_DENY) }) test('a link of theirs is named when it answered looser than handed', () => { diff --git a/mods/sec-default/tests/pinned-variable-refusal.test.ts b/mods/sec-default/tests/pinned-variable-refusal.test.ts new file mode 100644 index 0000000000..0fa23d05db --- /dev/null +++ b/mods/sec-default/tests/pinned-variable-refusal.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, test, tier } from 'claude-code/testing' + +import Hooks from '../hooks' + +tier('prepend') + +describe('pinned-variable-refusal', () => { + test('it names the setting and the variable, and not who set it', () => { + expect(Hooks.pinnedVariableRefusal('CORP_PROXY')).toBe( + 'env (managed): CORP_PROXY is not for plugins outside policy to change', + ) + }) +}) diff --git a/mods/sec-default/tests/policy/is-pinned-variable.test.ts b/mods/sec-default/tests/policy/is-pinned-variable.test.ts index dc3e02ba81..501751d4f1 100644 --- a/mods/sec-default/tests/policy/is-pinned-variable.test.ts +++ b/mods/sec-default/tests/policy/is-pinned-variable.test.ts @@ -22,6 +22,12 @@ describe('is-pinned-variable', () => { ).toEqual([true, true, false]) }) + test('a name managed env sets to nothing is pinned all the same', () => { + expect( + Policy.isPinnedVariable({ env: { CORP_PROXY: '' } }, 'CORP_PROXY'), + ).toBe(true) + }) + test('settings with no env pin nothing', () => { expect( [Fixtures.MANAGED_POLICY, {}, { env: {} }].map(policy => diff --git a/mods/sec-default/tests/register.test.ts b/mods/sec-default/tests/register.test.ts index c86d45b07b..befc769682 100644 --- a/mods/sec-default/tests/register.test.ts +++ b/mods/sec-default/tests/register.test.ts @@ -614,54 +614,6 @@ describe('register', () => { }, ) - test( - "a classic hook's ask holds over an allow from a plugin of the person", - { plugins: [Fixtures.allowing('easy')] }, - async ($, on) => { - on('settings.read', () => ({ value: Fixtures.MANAGED_POLICY })) - - const lines = Fixtures.logged(on) - - Fixtures.checksAnswered(on, Fixtures.HOOK_ASK) - - expect(await $.tool.check(Fixtures.CHECKED)).toEqual(Fixtures.HOOK_ASK) - - expect(lines).toEqual([ - `transcript: ${Hooks.heldNotice('easy', 'Bash', Fixtures.HOOK_ASK)}`, - ]) - }, - ) - - test( - "a classic hook's deny holds over their allow", - { plugins: [Fixtures.allowing('easy')] }, - async ($, on) => { - on('settings.read', () => ({ value: Fixtures.MANAGED_POLICY })) - - const lines = Fixtures.logged(on) - - Fixtures.checksAnswered(on, Fixtures.HOOK_DENY) - - expect(await $.tool.check(Fixtures.CHECKED)).toEqual(Fixtures.HOOK_DENY) - - expect(lines).toEqual([ - `transcript: ${Hooks.heldNotice('easy', 'Bash', Fixtures.HOOK_DENY)}`, - ]) - }, - ) - - test( - "a classic hook's deny holds over their ask", - { plugins: [Fixtures.asking('easy')] }, - async ($, on) => { - on('settings.read', () => ({ value: Fixtures.MANAGED_POLICY })) - Fixtures.logged(on) - Fixtures.checksAnswered(on, Fixtures.HOOK_DENY) - - expect(await $.tool.check(Fixtures.CHECKED)).toEqual(Fixtures.HOOK_DENY) - }, - ) - test( 'an allow that never called next meets the ask rule all the same', { plugins: [Fixtures.blindAllowing] }, @@ -721,7 +673,7 @@ describe('register', () => { const lines = Fixtures.logged(on) - Fixtures.checksAnswered(on, Fixtures.HOOK_ASK) + Fixtures.checksAnswered(on, Fixtures.RULE_ASK) expect(await $.tool.check(Fixtures.CHECKED)).toEqual(Fixtures.ALLOWED) expect(lines).toEqual([]) @@ -729,7 +681,7 @@ describe('register', () => { ) test( - "the mode's own ask, no rule or hook behind it, is theirs to allow", + "the mode's own ask, no rule behind it, is theirs to allow", { plugins: [Fixtures.allowing('easy')] }, async ($, on) => { on('settings.read', () => ({ value: Fixtures.MANAGED_POLICY })) @@ -744,7 +696,7 @@ describe('register', () => { ) test( - 'a deny holds whatever decided it: it may stand before a rule or a hook', + "a deny holds whatever decided it: it may stand before a rule's ask", { plugins: [Fixtures.allowing('easy')] }, async ($, on) => { on('settings.read', () => ({ value: Fixtures.MANAGED_POLICY })) @@ -826,6 +778,43 @@ describe('register', () => { }, ) + test( + 'each plugin is told of a kind, by the tool it answered on', + { plugins: [Fixtures.allowingBash, Fixtures.allowingRead] }, + async ($, on) => { + on('settings.read', () => ({ value: Fixtures.MANAGED_POLICY })) + + const lines = Fixtures.logged(on) + + Fixtures.checksAnswered(on, Fixtures.RULE_DENY) + + await $.tool.check(Fixtures.CHECKED) + await $.tool.check(Fixtures.CHECKED_READ) + + expect(lines).toEqual([ + `transcript: ${Hooks.heldNotice('basher', 'Bash', Fixtures.RULE_DENY)}`, + `transcript: ${Hooks.heldNotice('reader', 'Read', Fixtures.RULE_DENY)}`, + ]) + }, + ) + + test( + "an organization's appended plugin has its say in the run past theirs", + { + plugins: [Fixtures.allowing('suite', 'append'), Fixtures.blindAllowing], + }, + async ($, on) => { + on('settings.read', () => ({ value: Fixtures.MANAGED_POLICY })) + + const lines = Fixtures.logged(on) + + Fixtures.checksAnswered(on, Fixtures.RULE_ASK) + + expect(await $.tool.check(Fixtures.CHECKED)).toEqual(Fixtures.ALLOWED) + expect(lines).toEqual([]) + }, + ) + test( 'a plugin is told once of each kind of thing that held over it', { plugins: [Fixtures.allowing('easy')] }, @@ -837,24 +826,20 @@ describe('register', () => { Fixtures.RULE_DENY, Fixtures.RULE_ASK, Fixtures.RULE_ASK, - Fixtures.HOOK_ASK, Fixtures.PLAIN_DENY, Fixtures.RULE_DENY, ] const turn = Fixtures.checksAnsweredInTurn(on, verdicts) - for (const _ of verdicts) { - await $.tool.check(Fixtures.CHECKED) + for (const verdict of verdicts) { + expect(await $.tool.check(Fixtures.CHECKED)).toEqual(verdict) turn() } expect(lines).toEqual( - [ - Fixtures.RULE_DENY, - Fixtures.RULE_ASK, - Fixtures.HOOK_ASK, - Fixtures.PLAIN_DENY, - ].map(held => `transcript: ${Hooks.heldNotice('easy', 'Bash', held)}`), + [Fixtures.RULE_DENY, Fixtures.RULE_ASK, Fixtures.PLAIN_DENY].map( + held => `transcript: ${Hooks.heldNotice('easy', 'Bash', held)}`, + ), ) }, ) diff --git a/mods/types/claude-code.d.ts b/mods/types/claude-code.d.ts index 6c79c9ce3e..757b350d8e 100644 --- a/mods/types/claude-code.d.ts +++ b/mods/types/claude-code.d.ts @@ -3695,7 +3695,7 @@ declare module 'claude-code' { */ 'tool.call': ToolCallResult; /** - * `{ decision, reason?, rule?, hook? }`. + * `{ decision, reason?, rule? }`. */ 'tool.check': ToolCheckResult; /** @@ -9943,7 +9943,7 @@ declare module 'claude-code' { /** * What a `tool.check` hook returns and what `next(e)` resolves to: the - * verdict, why, and the settings rule or classic hook behind it, if any. + * verdict, why, and the settings rule behind it when one decided. * * 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,13 +9966,6 @@ declare module 'claude-code' { * Absent for a mode or a tool's own check. */ rule?: string; - /** - * The classic hook event that decided, or whose ask the verdict was reached - * under (`PreToolUse`), whoever configured the hook. - * - * Absent on a `$.tool.check` query, which runs no classic hook. - */ - hook?: string; }; /**