surshar / claude-code
Claude Code is an agentic coding tool that lives in your terminal, understands your codebase, and helps you code faster by executing routine tasks, explaining complex code, and handling git workflows - all through natural language commands.
Built by Anthropic · github.com/anthropics
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.
| @@ -4,9 +4,10 @@ | ||
| 4 | 4 | say on every event, in chain order, and the plugins a person installs sit |
| 5 | 5 | in the user tier, beneath the organization's prepend tier and above its |
| 6 | 6 | append tier. Some of what an organization sets today (its classic hooks, |
| 7 | -its managed CLAUDE.md and rules, its settings, its MCP allowlist, the deny | |
| 8 | -rules in force on its machines) was never within a person's reach before | |
| 9 | -function hooks; seated outermost, this plugin keeps exactly those out of the | |
| 7 | +its managed CLAUDE.md and rules, its settings and the environment they set, | |
| 8 | +its MCP allowlist, the permission rules and refusals in force on its machines) | |
| 9 | +was never within a person's reach before function hooks; seated outermost, | |
| 10 | +this plugin keeps exactly those, and the lines its own plugins log, out of the | |
| 10 | 11 | user tier's reach and adds no policy of its own. Everything else passes |
| 11 | 12 | through untouched. |
| 12 | 13 | |
| @@ -32,7 +33,7 @@ | ||
| 32 | 33 | | `tool.describe`, `command.describe`, `agent.offer`, `agent.spawn` | When the subject's pinned `e.provider.tier` is `prepend` or `append` (a policy-installed plugin, the managed folder, a policy MCP server), continue past the user tier; a subject provided by `user`, `builtin` or `core` passes. | |
| 33 | 34 | | `tool.register` | A caller in `prepend` or `append` continues past the user tier. A `user`-tier caller is refused by name while managed settings hold `allowedMcpServers` (set at all, empty included); otherwise it passes. | |
| 34 | 35 | | `tool.list` | The tools of the organization's managed MCP servers are listed as the organization's tiers listed them; every other tool as the user tier left it. With no policy to read, or a refusal from either listing, the organization's listing stands whole. | |
| 35 | -| `tool.check` | A deny, 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. | | |
| 36 | +| `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. | | |
| 36 | 37 | | `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. | |
| 37 | 38 | | `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. | |
| 38 | 39 | | `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 @@ | ||
| 89 | 90 | |
| 90 | 91 | `allowModsToOverrideDenyRules`: the plugins a person installs may answer |
| 91 | 92 | over a settings deny rule on `tool.check`, as they could before this plugin |
| 92 | -held deny rules, and over every other deny, an ask rule or a classic hook's | |
| 93 | -ask with it: the one option covers all that holds there. Off unless it is the | |
| 94 | -literal `true`; an option that reads as unset leaves them holding. See | |
| 93 | +held deny rules, and over every other deny and an ask rule with it: the one | |
| 94 | +option covers all that holds there. Off unless it is the literal `true`; an | |
| 95 | +option that reads as unset leaves them holding. See | |
| 95 | 96 | [Deny rules hold](#deny-rules-hold). |
| 96 | 97 | |
| 97 | 98 | ## What it hooks |
| @@ -118,8 +119,8 @@ | ||
| 118 | 119 | |
| 119 | 120 | On `tool.check` any hook may answer any verdict, so a plugin a person |
| 120 | 121 | installs to stop the permission prompts (`() => ({ decision: "allow" })`) |
| 121 | -would also lift a deny rule, an ask rule or a `PreToolUse` hook's ask, a | |
| 122 | -managed one included. Where this plugin is seated it does not: | |
| 122 | +would also lift a deny rule or an ask rule, a managed one included. Where | |
| 123 | +this plugin is seated it does not: | |
| 123 | 124 | |
| 124 | 125 | - The hook first runs the chain as it is. If the answer is a deny, or no |
| 125 | 126 | link that may hold a person's plugin answered more permissively than the |
| @@ -138,18 +139,18 @@ | ||
| 138 | 139 | changes nothing: the rules are evaluated in this run. The two runs differ |
| 139 | 140 | by the user tier alone, so a verdict here that is stricter than the chain's |
| 140 | 141 | answer is one the user tier loosened. A deny is returned in place of the |
| 141 | - chain's answer whatever decided it; an ask, when it names what decided it: | |
| 142 | - a settings rule (`rule`) or a classic hook (`hook`). | |
| 143 | -- Any rule and any classic hook counts, whoever configured it: a verdict | |
| 144 | - carries the rule as written and the hook's event, never where either was | |
| 145 | - read from. A deny holds unnamed because it can stand in front of a rule's or | |
| 146 | - a hook's ask: lifted, the call would run with nobody asked. An ask that names | |
| 147 | - neither (the mode's own, a check of the engine's own) is not held. | |
| 142 | + chain's answer whatever decided it; an ask, when it names the settings rule | |
| 143 | + that decided it (`rule`). | |
| 144 | +- Any rule counts, whoever configured it: a verdict carries the rule as | |
| 145 | + written, never where it was read from. A deny holds unnamed because it can | |
| 146 | + stand in front of a rule's ask: lifted, the call would run with nobody | |
| 147 | + asked. An ask that names no rule (the mode's own, a settings hook's, a check | |
| 148 | + of the engine's own, an organization's plugin's) is not held. | |
| 148 | 149 | - An organization's plugin (prepend or append) or a built-in that allows |
| 149 | 150 | over a deny rule takes part in both runs, so its answer stands (a prepended |
| 150 | 151 | one that loosens is what brings the second run about, so its hooks run |
| 151 | 152 | twice on such a call). The mode's own ask |
| 152 | - that a person's plugin turns into an allow, with no rule or hook behind it, | |
| 153 | + that a person's plugin turns into an allow, with no rule behind it, | |
| 153 | 154 | stands: that is what such a plugin is for. |
| 154 | 155 | - `tool.check` pins the question (`tool`, `input`, `tool_use_id`), so no hook |
| 155 | 156 | can have the rules evaluated on one command and another run; a rewrite |
| @@ -158,9 +159,8 @@ | ||
| 158 | 159 | transcript and the debug log: `<plugin> tried to lift a deny rule in your |
| 159 | 160 | settings from a <tool> call (<rule>); the deny rule holds over the plugins |
| 160 | 161 | you install (allowModsToOverrideDenyRules)`. The kinds are `a deny rule`, |
| 161 | - `an ask rule`, `a <event> hook's ask` and `a refusal` (a deny that names no | |
| 162 | - rule); the last two carry neither `in your settings` nor a rule. Plugins the | |
| 163 | - engine ran as one | |
| 162 | + `an ask rule` and `a refusal` (a deny that names no rule); the last carries | |
| 163 | + neither `in your settings` nor a rule. Plugins the engine ran as one | |
| 164 | 164 | batch are named together, as it names them (`audit+easy`). A plain `-p` |
| 165 | 165 | run has it in the debug log alone; the call is still denied, or asked |
| 166 | 166 | about, with its own message. The line goes past the user tier (the `ui.log` |
| @@ -179,7 +179,7 @@ | ||
| 179 | 179 | - If the hook itself fails, its `.catch` answers from the one run it can |
| 180 | 180 | read: a deny stands; a verdict no plugin of the person's loosened stands; |
| 181 | 181 | one they loosened, or a run that rejected, is refused, since the rules |
| 182 | - and hooks were never consulted. | |
| 182 | + were never consulted. | |
| 183 | 183 | |
| 184 | 184 | An organization that wants the plugins its people install to override deny |
| 185 | 185 | rules, and all else that holds with them, says so in managed settings, under |
| @@ -7,7 +7,7 @@ | ||
| 7 | 7 | * can read, the failed hook's last: that run's verdict, or a refusal. |
| 8 | 8 | * |
| 9 | 9 | * A deny stands, and so does a verdict no link that may hold a person's |
| 10 | - * plugin loosened. A loosened one, or none at all, met no rule or hook. | |
| 10 | + * plugin loosened. A loosened one, or none at all, met no rule. | |
| 11 | 11 | * |
| 12 | 12 | * @param last what that run settled on; undefined when it rejected |
| 13 | 13 | * @param trace that run's `next.trace` |
| @@ -2,18 +2,10 @@ | ||
| 2 | 2 | |
| 3 | 3 | /** |
| 4 | 4 | * What kind of thing held over a person's plugin, as the notice calls it: |
| 5 | - * `deny rule`, `ask rule`, `<event> hook's ask`, or `refusal`. | |
| 5 | + * `deny rule`, `ask rule`, or `refusal`, a deny that names no rule. | |
| 6 | 6 | * |
| 7 | - * A deny that names no rule is a refusal even when it names a hook: the hook | |
| 8 | - * may only have asked, and something else refused. | |
| 9 | - * | |
| 10 | 7 | * @param held the verdict that holds |
| 11 | 8 | * @returns the kind |
| 12 | 9 | */ |
| 13 | -export function heldKind(held: EventResult<'tool.check'>) { | |
| 14 | - if (held.rule !== undefined) { | |
| 15 | - return `${held.decision} rule` | |
| 16 | - } | |
| 17 | - | |
| 18 | - return held.decision === 'deny' ? 'refusal' : `${held.hook} hook's ask` | |
| 19 | -} | |
| 10 | +export const heldKind = (held: EventResult<'tool.check'>) => | |
| 11 | + held.rule === undefined ? 'refusal' : `${held.decision} rule` |
| @@ -19,14 +19,12 @@ | ||
| 19 | 19 | held: EventResult<'tool.check'>, |
| 20 | 20 | ) { |
| 21 | 21 | const kind = heldKind(held) |
| 22 | - const article = kind.startsWith('a') ? 'an' : 'a' | |
| 23 | - const lifted = | |
| 24 | - held.rule === undefined | |
| 25 | - ? `${article} ${kind} from a ${tool} call` | |
| 26 | - : `${article} ${kind} in your settings from a ${tool} call (${held.rule})` | |
| 22 | + const isRuled = held.rule !== undefined | |
| 27 | 23 | |
| 28 | 24 | return ( |
| 29 | - `${plugin} tried to lift ${lifted}; the ${kind} holds over the plugins ` + | |
| 30 | - 'you install (allowModsToOverrideDenyRules)' | |
| 25 | + `${plugin} tried to lift ${kind.startsWith('a') ? 'an' : 'a'} ${kind}` + | |
| 26 | + `${isRuled ? ' in your settings' : ''} from a ${tool} call` + | |
| 27 | + `${isRuled ? ` (${held.rule})` : ''}; the ${kind} holds over the ` + | |
| 28 | + 'plugins you install (allowModsToOverrideDenyRules)' | |
| 31 | 29 | ) |
| 32 | 30 | } |
| @@ -4,11 +4,11 @@ | ||
| 4 | 4 | |
| 5 | 5 | /** |
| 6 | 6 | * Whether the verdict reached past the user tier holds over the chain's |
| 7 | - * answer: it is the stricter, and a deny, or an ask a rule or a hook decided. | |
| 7 | + * answer: it is the stricter, and a deny, or an ask a settings rule decided. | |
| 8 | 8 | * |
| 9 | - * Any rule or classic hook counts, whoever configured it: a verdict names the | |
| 10 | - * rule and the hook's event, never their source. A deny holds unnamed: it may | |
| 11 | - * stand before a rule's or a hook's ask. The mode's own ask does not hold. | |
| 9 | + * Any rule counts, whoever configured it: a verdict names the rule, never its | |
| 10 | + * source. A deny holds unnamed: it may stand before a rule's ask. The mode's | |
| 11 | + * own ask does not hold, nor a settings hook's. | |
| 12 | 12 | * |
| 13 | 13 | * @param held what the run past the user tier settled on |
| 14 | 14 | * @param answer what the whole chain settled on |
| @@ -19,6 +19,4 @@ | ||
| 19 | 19 | answer: EventResult<'tool.check'>, |
| 20 | 20 | ) => |
| 21 | 21 | Ranking.isLooser(answer, held) && |
| 22 | - (held.decision === 'deny' || | |
| 23 | - held.rule !== undefined || | |
| 24 | - held.hook !== undefined) | |
| 22 | + (held.decision === 'deny' || held.rule !== undefined) |
| @@ -2,11 +2,11 @@ | ||
| 2 | 2 | |
| 3 | 3 | /** |
| 4 | 4 | * What the failure handler answers when a verdict was loosened, or never |
| 5 | - * reached, and no check of the rules and hooks vouches for it: a deny. | |
| 5 | + * reached, and no check of the rules vouches for it: a deny. | |
| 6 | 6 | */ |
| 7 | 7 | export const UNCHECKED_DENY: EventResult<'tool.check'> = Object.freeze({ |
| 8 | 8 | decision: 'deny', |
| 9 | 9 | reason: |
| 10 | - 'the rules and hooks in your settings could not be checked for this ' + | |
| 11 | - 'call, so it is refused', | |
| 10 | + 'the rules in your settings could not be checked for this call, ' + | |
| 11 | + 'so it is refused', | |
| 12 | 12 | }) |
| @@ -1,4 +1,4 @@ | ||
| 1 | 1 | { |
| 2 | - "description": "Security default: from the outermost seat, continues past the user tier on the organization's classic hooks, prompt content, settings and subjects, refuses a user-tier tool.register under an MCP allowlist, restores the organization's tools in tool.list, holds a 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", | |
| 2 | + "description": "Security default: from the outermost seat, continues past the user tier on the organization's classic hooks, prompt content, settings and subjects, refuses a user-tier tool.register under an MCP allowlist, restores the organization's tools in tool.list, holds a 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", | |
| 3 | 3 | "modules": ["./register.ts"] |
| 4 | 4 | } |
| @@ -6,7 +6,7 @@ | ||
| 6 | 6 | * Whether deny rules hold over the plugins a person installs: they do unless |
| 7 | 7 | * managed policy sets `allowModsToOverrideDenyRules` to the literal `true`. |
| 8 | 8 | * |
| 9 | - * Ask rules and classic hooks' answers hold or not with them. A value mistyped | |
| 9 | + * Every other deny, and ask rules, hold or not with them. A value mistyped | |
| 10 | 10 | * (`"true"`, `1`) loosens nothing. Only the policy source is handed in, so a |
| 11 | 11 | * person's settings never reach this. |
| 12 | 12 | * |
| @@ -1,0 +1,12 @@ | ||
| 1 | +import type { Plugin } from 'claude-code/testing' | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * A plugin the person installed that allows every Bash call and has no say | |
| 5 | + * on another tool's. | |
| 6 | + */ | |
| 7 | +export const allowingBash: Plugin = { | |
| 8 | + name: 'basher', | |
| 9 | + register(on) { | |
| 10 | + on('tool.check', { tool: 'Bash' }, () => ({ decision: 'allow' })) | |
| 11 | + }, | |
| 12 | +} |
| @@ -1,0 +1,12 @@ | ||
| 1 | +import type { Plugin } from 'claude-code/testing' | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * A plugin the person installed that allows every Read call and has no say | |
| 5 | + * on another tool's. | |
| 6 | + */ | |
| 7 | +export const allowingRead: Plugin = { | |
| 8 | + name: 'reader', | |
| 9 | + register(on) { | |
| 10 | + on('tool.check', { tool: 'Read' }, () => ({ decision: 'allow' })) | |
| 11 | + }, | |
| 12 | +} |
| @@ -1,0 +1,9 @@ | ||
| 1 | +import type { Args } from 'claude-code' | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * Another question the engine puts to `tool.check`: may Read read `notes`. | |
| 5 | + */ | |
| 6 | +export const CHECKED_READ: Args<'tool.check'> = { | |
| 7 | + tool: 'Read', | |
| 8 | + input: { file_path: 'notes' }, | |
| 9 | +} |
| @@ -1,11 +1,0 @@ | ||
| 1 | -import type { EventResult } from 'claude-code' | |
| 2 | - | |
| 3 | -/** | |
| 4 | - * The engine's verdict when a PreToolUse hook asked for a person: the ask, | |
| 5 | - * the hook's sentence, and the hook's event. | |
| 6 | - */ | |
| 7 | -export const HOOK_ASK: EventResult<'tool.check'> = { | |
| 8 | - decision: 'ask', | |
| 9 | - reason: 'a person approves every echo', | |
| 10 | - hook: 'PreToolUse', | |
| 11 | -} |
| @@ -1,11 +1,0 @@ | ||
| 1 | -import type { EventResult } from 'claude-code' | |
| 2 | - | |
| 3 | -/** | |
| 4 | - * The engine's verdict when a PreToolUse hook refused the call: the deny, | |
| 5 | - * the hook's sentence, and the hook's event. | |
| 6 | - */ | |
| 7 | -export const HOOK_DENY: EventResult<'tool.check'> = { | |
| 8 | - decision: 'deny', | |
| 9 | - reason: 'no echo here', | |
| 10 | - hook: 'PreToolUse', | |
| 11 | -} |
| @@ -1,14 +1,15 @@ | ||
| 1 | 1 | export * from './allowed.js' |
| 2 | 2 | export * from './allowing.js' |
| 3 | +export * from './allowing-bash.js' | |
| 4 | +export * from './allowing-read.js' | |
| 3 | 5 | export * from './asked.js' |
| 4 | 6 | export * from './asking.js' |
| 5 | 7 | export * from './blind-allowing.js' |
| 6 | 8 | export * from './checked.js' |
| 9 | +export * from './checked-read.js' | |
| 7 | 10 | export * from './checks-answered.js' |
| 8 | 11 | export * from './checks-answered-in-turn.js' |
| 9 | 12 | export * from './forging.js' |
| 10 | -export * from './hook-ask.js' | |
| 11 | -export * from './hook-deny.js' | |
| 12 | 13 | export * from './link-of.js' |
| 13 | 14 | export * from './listening.js' |
| 14 | 15 | export * from './muting.js' |
| @@ -1,8 +1,8 @@ | ||
| 1 | 1 | import type { EventResult } from 'claude-code' |
| 2 | 2 | |
| 3 | 3 | /** |
| 4 | - * A deny that neither a settings rule nor a classic hook decided (a tool's | |
| 5 | - * own check, a setting that is no rule, a plugin's answer): it names neither. | |
| 4 | + * A deny no settings rule decided (a tool's own check, a setting that is no | |
| 5 | + * rule, a plugin's answer): it names none. | |
| 6 | 6 | */ |
| 7 | 7 | export const PLAIN_DENY: EventResult<'tool.check'> = { |
| 8 | 8 | decision: 'deny', |
| @@ -6,70 +6,51 @@ | ||
| 6 | 6 | tier('prepend') |
| 7 | 7 | |
| 8 | 8 | describe('held-verdict', () => { |
| 9 | - test('over an allow: any deny, and an ask a rule or a hook decided', () => { | |
| 9 | + test('over an allow: any deny, and an ask a settings rule decided', () => { | |
| 10 | 10 | expect( |
| 11 | 11 | [ |
| 12 | 12 | Fixtures.RULE_DENY, |
| 13 | 13 | Fixtures.RULE_ASK, |
| 14 | - Fixtures.HOOK_DENY, | |
| 15 | - Fixtures.HOOK_ASK, | |
| 16 | 14 | Fixtures.PLAIN_DENY, |
| 17 | 15 | Fixtures.ASKED, |
| 18 | 16 | Fixtures.ALLOWED, |
| 19 | 17 | ].map(held => Hooks.holdsOver(held, Fixtures.ALLOWED)), |
| 20 | - ).toEqual([true, true, true, true, true, false, false]) | |
| 18 | + ).toEqual([true, true, true, false, false]) | |
| 21 | 19 | }) |
| 22 | 20 | |
| 23 | 21 | test('over an ask only a deny is stricter; over a deny nothing is', () => { |
| 24 | 22 | expect( |
| 25 | - [ | |
| 26 | - Fixtures.RULE_DENY, | |
| 27 | - Fixtures.RULE_ASK, | |
| 28 | - Fixtures.HOOK_DENY, | |
| 29 | - Fixtures.HOOK_ASK, | |
| 30 | - ].map(held => [ | |
| 23 | + [Fixtures.RULE_DENY, Fixtures.RULE_ASK].map(held => [ | |
| 31 | 24 | Hooks.holdsOver(held, Fixtures.ASKED), |
| 32 | 25 | Hooks.holdsOver(held, Fixtures.PLAIN_DENY), |
| 33 | 26 | ]), |
| 34 | 27 | ).toEqual([ |
| 35 | 28 | [true, false], |
| 36 | 29 | [false, false], |
| 37 | - [true, false], | |
| 38 | - [false, false], | |
| 39 | 30 | ]) |
| 40 | 31 | }) |
| 41 | 32 | |
| 42 | - test('a rule or hook the answer itself names makes nothing hold', () => { | |
| 33 | + test('a rule the answer itself names makes nothing hold', () => { | |
| 43 | 34 | expect( |
| 44 | 35 | Hooks.holdsOver(Fixtures.ASKED, { |
| 45 | 36 | ...Fixtures.ALLOWED, |
| 46 | 37 | rule: 'Bash(echo *)', |
| 47 | - hook: 'PreToolUse', | |
| 48 | 38 | }), |
| 49 | 39 | ).toBe(false) |
| 50 | 40 | }) |
| 51 | 41 | |
| 52 | 42 | test('the notice names the kind that held, and the rule when one did', () => { |
| 53 | 43 | expect( |
| 54 | - [ | |
| 55 | - Fixtures.RULE_DENY, | |
| 56 | - Fixtures.RULE_ASK, | |
| 57 | - { ...Fixtures.RULE_ASK, ...Fixtures.HOOK_ASK }, | |
| 58 | - Fixtures.HOOK_ASK, | |
| 59 | - Fixtures.HOOK_DENY, | |
| 60 | - Fixtures.PLAIN_DENY, | |
| 61 | - ].map(held => Hooks.heldNotice('easy', 'Bash', held)), | |
| 44 | + [Fixtures.RULE_DENY, Fixtures.RULE_ASK, Fixtures.PLAIN_DENY].map(held => | |
| 45 | + Hooks.heldNotice('easy', 'Bash', held), | |
| 46 | + ), | |
| 62 | 47 | ).toEqual( |
| 63 | 48 | [ |
| 64 | 49 | 'a deny rule in your settings from a Bash call (Bash(echo *)); ' + |
| 65 | 50 | 'the deny rule', |
| 66 | 51 | 'an ask rule in your settings from a Bash call (Bash(echo *)); ' + |
| 67 | 52 | 'the ask rule', |
| 68 | - 'an ask rule in your settings from a Bash call (Bash(echo *)); ' + | |
| 69 | - 'the ask rule', | |
| 70 | - "a PreToolUse hook's ask from a Bash call; the PreToolUse hook's ask", | |
| 71 | 53 | 'a refusal from a Bash call; the refusal', |
| 72 | - 'a refusal from a Bash call; the refusal', | |
| 73 | 54 | ].map( |
| 74 | 55 | middle => |
| 75 | 56 | `easy tried to lift ${middle} holds over the plugins you install ` + |
| @@ -78,12 +59,24 @@ | ||
| 78 | 59 | ) |
| 79 | 60 | }) |
| 80 | 61 | |
| 81 | - test('the refusal of a pinned variable names the setting and it', () => { | |
| 82 | - expect(Hooks.pinnedVariableRefusal('CORP_PROXY')).toBe( | |
| 83 | - 'env (managed): CORP_PROXY is not for plugins outside policy to change', | |
| 84 | - ) | |
| 62 | + test('what no check vouches for is refused, and says why', () => { | |
| 63 | + expect(Hooks.UNCHECKED_DENY).toEqual({ | |
| 64 | + decision: 'deny', | |
| 65 | + reason: | |
| 66 | + 'the rules in your settings could not be checked for this call, ' + | |
| 67 | + 'so it is refused', | |
| 68 | + }) | |
| 85 | 69 | }) |
| 86 | 70 | |
| 71 | + test('an ask of theirs over a deny, caught, is no more vouched for', () => { | |
| 72 | + expect( | |
| 73 | + Hooks.caughtAnswer(Fixtures.ASKED, [ | |
| 74 | + Fixtures.linkOf('easy', 'user', Fixtures.ASKED), | |
| 75 | + Fixtures.linkOf('engine', 'core', Fixtures.RULE_DENY), | |
| 76 | + ]), | |
| 77 | + ).toEqual(Hooks.UNCHECKED_DENY) | |
| 78 | + }) | |
| 79 | + | |
| 87 | 80 | test('a link of theirs is named when it answered looser than handed', () => { |
| 88 | 81 | expect( |
| 89 | 82 | Hooks.loosenedByUsers([ |
| @@ -1,0 +1,13 @@ | ||
| 1 | +import { describe, expect, test, tier } from 'claude-code/testing' | |
| 2 | + | |
| 3 | +import Hooks from '../hooks' | |
| 4 | + | |
| 5 | +tier('prepend') | |
| 6 | + | |
| 7 | +describe('pinned-variable-refusal', () => { | |
| 8 | + test('it names the setting and the variable, and not who set it', () => { | |
| 9 | + expect(Hooks.pinnedVariableRefusal('CORP_PROXY')).toBe( | |
| 10 | + 'env (managed): CORP_PROXY is not for plugins outside policy to change', | |
| 11 | + ) | |
| 12 | + }) | |
| 13 | +}) |
| @@ -22,6 +22,12 @@ | ||
| 22 | 22 | ).toEqual([true, true, false]) |
| 23 | 23 | }) |
| 24 | 24 | |
| 25 | + test('a name managed env sets to nothing is pinned all the same', () => { | |
| 26 | + expect( | |
| 27 | + Policy.isPinnedVariable({ env: { CORP_PROXY: '' } }, 'CORP_PROXY'), | |
| 28 | + ).toBe(true) | |
| 29 | + }) | |
| 30 | + | |
| 25 | 31 | test('settings with no env pin nothing', () => { |
| 26 | 32 | expect( |
| 27 | 33 | [Fixtures.MANAGED_POLICY, {}, { env: {} }].map(policy => |
| @@ -615,54 +615,6 @@ | ||
| 615 | 615 | ) |
| 616 | 616 | |
| 617 | 617 | test( |
| 618 | - "a classic hook's ask holds over an allow from a plugin of the person", | |
| 619 | - { plugins: [Fixtures.allowing('easy')] }, | |
| 620 | - async ($, on) => { | |
| 621 | - on('settings.read', () => ({ value: Fixtures.MANAGED_POLICY })) | |
| 622 | - | |
| 623 | - const lines = Fixtures.logged(on) | |
| 624 | - | |
| 625 | - Fixtures.checksAnswered(on, Fixtures.HOOK_ASK) | |
| 626 | - | |
| 627 | - expect(await $.tool.check(Fixtures.CHECKED)).toEqual(Fixtures.HOOK_ASK) | |
| 628 | - | |
| 629 | - expect(lines).toEqual([ | |
| 630 | - `transcript: ${Hooks.heldNotice('easy', 'Bash', Fixtures.HOOK_ASK)}`, | |
| 631 | - ]) | |
| 632 | - }, | |
| 633 | - ) | |
| 634 | - | |
| 635 | - test( | |
| 636 | - "a classic hook's deny holds over their allow", | |
| 637 | - { plugins: [Fixtures.allowing('easy')] }, | |
| 638 | - async ($, on) => { | |
| 639 | - on('settings.read', () => ({ value: Fixtures.MANAGED_POLICY })) | |
| 640 | - | |
| 641 | - const lines = Fixtures.logged(on) | |
| 642 | - | |
| 643 | - Fixtures.checksAnswered(on, Fixtures.HOOK_DENY) | |
| 644 | - | |
| 645 | - expect(await $.tool.check(Fixtures.CHECKED)).toEqual(Fixtures.HOOK_DENY) | |
| 646 | - | |
| 647 | - expect(lines).toEqual([ | |
| 648 | - `transcript: ${Hooks.heldNotice('easy', 'Bash', Fixtures.HOOK_DENY)}`, | |
| 649 | - ]) | |
| 650 | - }, | |
| 651 | - ) | |
| 652 | - | |
| 653 | - test( | |
| 654 | - "a classic hook's deny holds over their ask", | |
| 655 | - { plugins: [Fixtures.asking('easy')] }, | |
| 656 | - async ($, on) => { | |
| 657 | - on('settings.read', () => ({ value: Fixtures.MANAGED_POLICY })) | |
| 658 | - Fixtures.logged(on) | |
| 659 | - Fixtures.checksAnswered(on, Fixtures.HOOK_DENY) | |
| 660 | - | |
| 661 | - expect(await $.tool.check(Fixtures.CHECKED)).toEqual(Fixtures.HOOK_DENY) | |
| 662 | - }, | |
| 663 | - ) | |
| 664 | - | |
| 665 | - test( | |
| 666 | 618 | 'an allow that never called next meets the ask rule all the same', |
| 667 | 619 | { plugins: [Fixtures.blindAllowing] }, |
| 668 | 620 | async ($, on) => { |
| @@ -721,7 +673,7 @@ | ||
| 721 | 673 | |
| 722 | 674 | const lines = Fixtures.logged(on) |
| 723 | 675 | |
| 724 | - Fixtures.checksAnswered(on, Fixtures.HOOK_ASK) | |
| 676 | + Fixtures.checksAnswered(on, Fixtures.RULE_ASK) | |
| 725 | 677 | |
| 726 | 678 | expect(await $.tool.check(Fixtures.CHECKED)).toEqual(Fixtures.ALLOWED) |
| 727 | 679 | expect(lines).toEqual([]) |
| @@ -729,7 +681,7 @@ | ||
| 729 | 681 | ) |
| 730 | 682 | |
| 731 | 683 | test( |
| 732 | - "the mode's own ask, no rule or hook behind it, is theirs to allow", | |
| 684 | + "the mode's own ask, no rule behind it, is theirs to allow", | |
| 733 | 685 | { plugins: [Fixtures.allowing('easy')] }, |
| 734 | 686 | async ($, on) => { |
| 735 | 687 | on('settings.read', () => ({ value: Fixtures.MANAGED_POLICY })) |
| @@ -744,7 +696,7 @@ | ||
| 744 | 696 | ) |
| 745 | 697 | |
| 746 | 698 | test( |
| 747 | - 'a deny holds whatever decided it: it may stand before a rule or a hook', | |
| 699 | + "a deny holds whatever decided it: it may stand before a rule's ask", | |
| 748 | 700 | { plugins: [Fixtures.allowing('easy')] }, |
| 749 | 701 | async ($, on) => { |
| 750 | 702 | on('settings.read', () => ({ value: Fixtures.MANAGED_POLICY })) |
| @@ -827,6 +779,43 @@ | ||
| 827 | 779 | ) |
| 828 | 780 | |
| 829 | 781 | test( |
| 782 | + 'each plugin is told of a kind, by the tool it answered on', | |
| 783 | + { plugins: [Fixtures.allowingBash, Fixtures.allowingRead] }, | |
| 784 | + async ($, on) => { | |
| 785 | + on('settings.read', () => ({ value: Fixtures.MANAGED_POLICY })) | |
| 786 | + | |
| 787 | + const lines = Fixtures.logged(on) | |
| 788 | + | |
| 789 | + Fixtures.checksAnswered(on, Fixtures.RULE_DENY) | |
| 790 | + | |
| 791 | + await $.tool.check(Fixtures.CHECKED) | |
| 792 | + await $.tool.check(Fixtures.CHECKED_READ) | |
| 793 | + | |
| 794 | + expect(lines).toEqual([ | |
| 795 | + `transcript: ${Hooks.heldNotice('basher', 'Bash', Fixtures.RULE_DENY)}`, | |
| 796 | + `transcript: ${Hooks.heldNotice('reader', 'Read', Fixtures.RULE_DENY)}`, | |
| 797 | + ]) | |
| 798 | + }, | |
| 799 | + ) | |
| 800 | + | |
| 801 | + test( | |
| 802 | + "an organization's appended plugin has its say in the run past theirs", | |
| 803 | + { | |
| 804 | + plugins: [Fixtures.allowing('suite', 'append'), Fixtures.blindAllowing], | |
| 805 | + }, | |
| 806 | + async ($, on) => { | |
| 807 | + on('settings.read', () => ({ value: Fixtures.MANAGED_POLICY })) | |
| 808 | + | |
| 809 | + const lines = Fixtures.logged(on) | |
| 810 | + | |
| 811 | + Fixtures.checksAnswered(on, Fixtures.RULE_ASK) | |
| 812 | + | |
| 813 | + expect(await $.tool.check(Fixtures.CHECKED)).toEqual(Fixtures.ALLOWED) | |
| 814 | + expect(lines).toEqual([]) | |
| 815 | + }, | |
| 816 | + ) | |
| 817 | + | |
| 818 | + test( | |
| 830 | 819 | 'a plugin is told once of each kind of thing that held over it', |
| 831 | 820 | { plugins: [Fixtures.allowing('easy')] }, |
| 832 | 821 | async ($, on) => { |
| @@ -837,24 +826,20 @@ | ||
| 837 | 826 | Fixtures.RULE_DENY, |
| 838 | 827 | Fixtures.RULE_ASK, |
| 839 | 828 | Fixtures.RULE_ASK, |
| 840 | - Fixtures.HOOK_ASK, | |
| 841 | 829 | Fixtures.PLAIN_DENY, |
| 842 | 830 | Fixtures.RULE_DENY, |
| 843 | 831 | ] |
| 844 | 832 | const turn = Fixtures.checksAnsweredInTurn(on, verdicts) |
| 845 | 833 | |
| 846 | - for (const _ of verdicts) { | |
| 847 | - await $.tool.check(Fixtures.CHECKED) | |
| 834 | + for (const verdict of verdicts) { | |
| 835 | + expect(await $.tool.check(Fixtures.CHECKED)).toEqual(verdict) | |
| 848 | 836 | turn() |
| 849 | 837 | } |
| 850 | 838 | |
| 851 | 839 | expect(lines).toEqual( |
| 852 | - [ | |
| 853 | - Fixtures.RULE_DENY, | |
| 854 | - Fixtures.RULE_ASK, | |
| 855 | - Fixtures.HOOK_ASK, | |
| 856 | - Fixtures.PLAIN_DENY, | |
| 857 | - ].map(held => `transcript: ${Hooks.heldNotice('easy', 'Bash', held)}`), | |
| 840 | + [Fixtures.RULE_DENY, Fixtures.RULE_ASK, Fixtures.PLAIN_DENY].map( | |
| 841 | + held => `transcript: ${Hooks.heldNotice('easy', 'Bash', held)}`, | |
| 842 | + ), | |
| 858 | 843 | ) |
| 859 | 844 | }, |
| 860 | 845 | ) |
Large file, diff not shown.