spacehub

surshar / claude-code

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

Built by Anthropic · github.com/anthropics

sec-default: a settings deny rule holds over an allow or ask from a plugin the person installed (#98080)Browse files
* sec-default: a settings deny rule holds over an allow or ask from a plugin the person installed

* sec-default: the tool.check test plugins carry their verdicts in their own bodies

* sec-default: a user-tier link counts as loosening only when it answers looser than it was handed

* sec-default: the README says where the line lands in a plain -p run

* sec-default: a batch listed under prepend may hold a person's plugin, and the line says lift

* sec-default: the README and one doc comment say what the batch fix changed
Alice T'Poteat authored 24 days ago · GitHub committed 24 days ago parent 0d7f14d · commit 16da1ecd3ff1
43 changed files+1045 -11compared with 0d7f14d
Mmods/README.md
Mmods/sec-default/README.md
Amods/sec-default/hooks/held-verdict/caught-answer.ts
Amods/sec-default/hooks/held-verdict/held-notice.ts
Amods/sec-default/hooks/held-verdict/index.ts
Amods/sec-default/hooks/held-verdict/verdicts/index.ts
Amods/sec-default/hooks/held-verdict/verdicts/is-rule-deny.ts
Amods/sec-default/hooks/held-verdict/verdicts/loosened-by-users.ts
Amods/sec-default/hooks/held-verdict/verdicts/ranking/handed-to.ts
Amods/sec-default/hooks/held-verdict/verdicts/ranking/index.ts
Amods/sec-default/hooks/held-verdict/verdicts/ranking/is-looser.ts
Amods/sec-default/hooks/held-verdict/verdicts/ranking/leniency/index.ts
Amods/sec-default/hooks/held-verdict/verdicts/ranking/leniency/leniency.ts
Amods/sec-default/hooks/held-verdict/verdicts/ranking/tiers-holding-users/index.ts
Amods/sec-default/hooks/held-verdict/verdicts/ranking/tiers-holding-users/tiers-holding-users.ts
Amods/sec-default/hooks/held-verdict/verdicts/types/index.ts
Amods/sec-default/hooks/held-verdict/verdicts/types/rule-deny.ts
Amods/sec-default/hooks/held-verdict/verdicts/unchecked-deny.ts
Mmods/sec-default/hooks/hooks.json
Mmods/sec-default/hooks/index.ts
Amods/sec-default/hooks/policy/deny-rules-hold.ts
Mmods/sec-default/hooks/policy/index.ts
Mmods/sec-default/hooks/register.ts
Mmods/sec-default/tests/fixtures/index.ts
Amods/sec-default/tests/fixtures/tool-check/allowed.ts
Amods/sec-default/tests/fixtures/tool-check/allowing.ts
Amods/sec-default/tests/fixtures/tool-check/asked.ts
Amods/sec-default/tests/fixtures/tool-check/asking.ts
Amods/sec-default/tests/fixtures/tool-check/blind-allowing.ts
Amods/sec-default/tests/fixtures/tool-check/checked.ts
Amods/sec-default/tests/fixtures/tool-check/checks-answered.ts
Amods/sec-default/tests/fixtures/tool-check/forging.ts
Amods/sec-default/tests/fixtures/tool-check/index.ts
Amods/sec-default/tests/fixtures/tool-check/link-of.ts
Amods/sec-default/tests/fixtures/tool-check/listening.ts
Amods/sec-default/tests/fixtures/tool-check/override-policy-of.ts
Amods/sec-default/tests/fixtures/tool-check/plain-deny.ts
Amods/sec-default/tests/fixtures/tool-check/rewriting.ts
Amods/sec-default/tests/fixtures/tool-check/rule-deny.ts
Amods/sec-default/tests/fixtures/tool-check/tightening.ts
Amods/sec-default/tests/held-verdict.test.ts
Amods/sec-default/tests/policy/deny-rules-hold.test.ts
Mmods/sec-default/tests/register.test.ts
@@ -7,7 +7,7 @@
77
88 | Mod | What it does | Seated |
99 | --- | --- | --- |
10-| [`sec-default`](sec-default) | Keeps an organization's classic hooks, prompt content, managed settings and tool policy 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 |
10+| [`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 |
1111 | [`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 |
1212 | [`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 |
1313 | [`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 |
@@ -4,10 +4,11 @@
44 say on every event, in chain order, and the plugins a person installs sit
55 in the user tier, beneath the organization's prepend tier and above its
66 append tier. Some of what an organization sets today (its classic hooks,
7-its managed CLAUDE.md and rules, its settings, its MCP allowlist) was never
8-within a person's reach before function hooks; seated outermost, this
9-plugin keeps exactly those out of the user tier's reach and adds no policy
10-of its own. Everything else passes through untouched.
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
10+user tier's reach and adds no policy of its own. Everything else passes
11+through untouched.
1112
1213 It has three moves and nothing else: continue past the user tier
1314 (`next.to(e, "append")`), refuse a user-tier caller or module by name
@@ -30,12 +31,13 @@
3031 | `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. |
3132 | `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. |
3233 | `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. |
34+| `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. |
3335 | `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. |
34-| everything else | Passes: `prompt.submit`, `turn.*`, `tool.call`, `tool.check`, `command.run`, `command.register`, `session.*`, `ui.*`, `fs.*`, `http.fetch`, `process.run`, `store.*`, `clock.*`, `model.*`, `mcp.call`, `audio.*`, `agent.list`, `engine.create`. |
36+| 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`. |
3537
3638 ## Options an administrator sets
3739
38-One, in managed settings, under this plugin's own `pluginConfigs` entry,
40+Two, in managed settings, under this plugin's own `pluginConfigs` entry,
3941 keyed by the id the CLI builds the plugin in under (only this spelling of the
4042 id is read):
4143
@@ -82,18 +84,96 @@
8284 - `claude plugin test` is not covered: it runs a mod's tests in an engine of
8385 their own and loads nothing into a session.
8486
87+`allowModsToOverrideDenyRules`: the plugins a person installs may answer
88+over a settings deny rule on `tool.check`, as they could before this plugin
89+held deny rules. Off unless it is the literal `true`; an option that reads
90+as unset leaves deny rules holding. See [Deny rules hold](#deny-rules-hold).
91+
8592 ## What it hooks
8693
8794 `classic.*`, `prompt.section`, `prompt.context`, `skill.prompt`,
8895 `attribution.text`, `settings.read`, `tool.describe`, `command.describe`,
89-`agent.offer`, `agent.spawn`, `tool.register`, `tool.list`,
96+`agent.offer`, `agent.spawn`, `tool.register`, `tool.list`, `tool.check`,
9097 `plugin.register`.
9198
99+Hooking `tool.check` has a cost: the engine raises that event only when some
100+loaded plugin hooks it, so where this plugin is seated every tool call now
101+runs the `tool.check` chain, where before only a session with such a plugin
102+did.
103+
92104 ## What it calls on `$`
93105
94-`settings.read`, and `ui.log` to the debug log. It continues to the `append`
95-tier with `next.to`, which only a plugin in a managed tier may do.
106+`settings.read`, and `ui.log`: to the debug log, and for the one line a
107+person reads when a deny rule held over a plugin of theirs. It continues to
108+the `append` tier with `next.to`, which only a plugin in a managed tier may
109+do.
96110
111+## Deny rules hold
112+
113+On `tool.check` any hook may answer any verdict, so a plugin a person
114+installs to stop the permission prompts (`() => ({ decision: "allow" })`)
115+would also lift a deny rule, a managed one included. Where this plugin is
116+seated it does not:
117+
118+- The hook first runs the chain as it is. If the answer is a deny, or no
119+ link that may hold a person's plugin answered more permissively than the
120+ verdict handed up to it, the answer passes: nothing of the person's
121+ loosened anything, and a plugin that only listens adds no run of its own.
122+ This is read off `next.trace`, whose tiers the engine pins: a link that never
123+ called `next` is measured against a deny, and since the engine lists
124+ neighbouring plugins that share a worker as one batch under its first
125+ member's tier, a `prepend` entry beneath this plugin counts as well as a
126+ `user` one. Whether a person's plugin did the loosening is never settled
127+ here, only by the next step.
128+- Otherwise it runs the dispatch once more with the user tier left out
129+ (`next.to(e, "append")`). That verdict never passed through a person's
130+ plugin, so neither the decision nor the rule it names can have been
131+ rewritten or erased, and a plugin that answered without calling `next`
132+ changes nothing: the rules are evaluated in this run. The two runs differ
133+ by the user tier alone, so a deny here that names its rule is a deny rule
134+ the user tier loosened, and it is returned in place of the chain's answer.
135+- Any deny rule counts, whatever settings file it came from: a verdict
136+ carries the rule as written, never where it was read from. A deny that
137+ names no rule (a settings hook's, a tool's own check) is not held.
138+- An organization's plugin (prepend or append) or a built-in that allows
139+ over a deny rule takes part in both runs, so its answer stands (a prepended
140+ one that loosens is what brings the second run about, so its hooks run
141+ twice on such a call). An ask
142+ that a person's plugin turns into an allow, with no deny rule behind it,
143+ stands: that is what such a plugin is for.
144+- `tool.check` pins the question (`tool`, `input`, `tool_use_id`), so no hook
145+ can have the rules evaluated on one command and another run; a rewrite
146+ belongs to `tool.call`, which runs before any of this.
147+- The person is told once for each name in a session, in the transcript
148+ and the debug log: `<plugin> tried to lift a deny rule in your settings
149+ from a <tool> call (<rule>); the deny rule holds over the plugins you
150+ install (allowModsToOverrideDenyRules)`. Plugins the engine ran as one
151+ batch are named together, as it names them (`audit+easy`). A plain `-p`
152+ run has it in the debug log alone; the call is still denied with the
153+ rule's own message.
154+- If the hook itself fails, its `.catch` answers from the one run it can
155+ read: a deny stands; a verdict no plugin of the person's loosened stands;
156+ one they loosened, or a run that rejected, is refused, since the deny
157+ rules were never consulted.
158+
159+An organization that wants the plugins its people install to override deny
160+rules says so in managed settings, under this plugin's own options:
161+
162+```json
163+{
164+ "pluginConfigs": {
165+ "cc-plugin-sec-default@builtin": {
166+ "options": { "allowModsToOverrideDenyRules": true }
167+ }
168+ }
169+}
170+```
171+
172+Only the managed source is read (`$.settings.read({ source: "policy" })`),
173+so the same key in a person's, a project's or a local settings file, or in
174+`--settings`, is never consulted; only the literal `true` counts, and a
175+policy that cannot be read leaves deny rules holding.
176+
97177 ## Where it is seated
98178
99179 The CLI seats it first in the prepend tier wherever hooks modules load on a
@@ -1,0 +1,25 @@
1+import type { EventResult, TraceEntry } from 'claude-code'
2+
3+import Verdicts from './verdicts'
4+
5+/**
6+ * What the `tool.check` hook's failure handler answers from the one run it
7+ * can read, the failed hook's last: that run's verdict, or a refusal.
8+ *
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 deny rule.
11+ *
12+ * @param last what that run settled on; undefined when it rejected
13+ * @param trace that run's `next.trace`
14+ * @returns the verdict the handler returns
15+ */
16+export function caughtAnswer(
17+ last: EventResult<'tool.check'> | undefined,
18+ trace: readonly TraceEntry<'tool.check'>[],
19+) {
20+ const isVouched =
21+ last !== undefined &&
22+ (last.decision === 'deny' || Verdicts.loosenedByUsers(trace).length === 0)
23+
24+ return isVouched ? last : Verdicts.UNCHECKED_DENY
25+}
@@ -1,0 +1,15 @@
1+/**
2+ * What a person reads, once for each plugin in a session, when a plugin they
3+ * installed answered allow or ask over a deny rule in their settings.
4+ *
5+ * It names the option an administrator sets to let such plugins override.
6+ *
7+ * @param plugin the plugin's name, or its batch's, as the trace names it
8+ * @param tool the tool the call named
9+ * @param rule the deny rule that decided, as written
10+ * @returns the line
11+ */
12+export const heldNotice = (plugin: string, tool: string, rule: string) =>
13+ `${plugin} tried to lift a deny rule in your settings from a ${tool} ` +
14+ `call (${rule}); the deny rule holds over the plugins you install ` +
15+ '(allowModsToOverrideDenyRules)'
@@ -1,0 +1,5 @@
1+export * from './caught-answer.js'
2+export * from './held-notice.js'
3+export * from './verdicts'
4+
5+export * as default from '.'
@@ -1,0 +1,7 @@
1+export * from './is-rule-deny.js'
2+export * from './loosened-by-users.js'
3+export * from './ranking'
4+export * from './types'
5+export * from './unchecked-deny.js'
6+
7+export * as default from '.'
@@ -1,0 +1,18 @@
1+import type { EventResult } from 'claude-code'
2+
3+import type { RuleDeny } from './types'
4+
5+/**
6+ * Whether a `tool.check` verdict is a deny that a settings rule decided: the
7+ * one verdict the plugins a person installs may not loosen.
8+ *
9+ * Any deny rule counts, whatever settings file it came from: a verdict
10+ * carries the rule as written and never where it was read from.
11+ *
12+ * @param verdict what a run of the chain settled on
13+ * @returns true for a deny that names the rule behind it
14+ */
15+export const isRuleDeny = (
16+ verdict: EventResult<'tool.check'>,
17+): verdict is RuleDeny =>
18+ verdict.decision === 'deny' && verdict.rule !== undefined
@@ -1,0 +1,25 @@
1+import type { TraceEntry } from 'claude-code'
2+
3+import Ranking from './ranking'
4+
5+/**
6+ * The links of one run of `tool.check` that may hold a plugin a person
7+ * installed and that answered more permissively than they were handed.
8+ *
9+ * Read off `next.trace`, whose `tier` the engine pins. A batch is named as
10+ * the engine names it, its members joined; whether a person's plugin did
11+ * the loosening is settled by the run past the user tier, never here.
12+ *
13+ * @param trace what settled beneath the hook on its latest `next` call
14+ * @returns their names, nearest the caller first; none when none loosened
15+ */
16+export const loosenedByUsers = (
17+ trace: readonly TraceEntry<'tool.check'>[],
18+): readonly string[] =>
19+ trace
20+ .filter(
21+ (link, at) =>
22+ Ranking.TIERS_HOLDING_USERS.includes(link.tier) &&
23+ Ranking.isLooser(link.returned, Ranking.handedTo(trace, at)),
24+ )
25+ .map(link => link.plugin)
@@ -1,0 +1,17 @@
1+import type { TraceEntry } from 'claude-code'
2+
3+/**
4+ * The verdict handed up to one link of a run: what the nearest link beneath
5+ * it that settled on anything settled on.
6+ *
7+ * Undefined for a link that answered without calling `next`: the trace ends
8+ * short of the engine there, and nothing beneath it ran.
9+ *
10+ * @param trace a run as `next.trace` lists it, nearest the caller first
11+ * @param at the link's place in that list
12+ * @returns the verdict, or undefined when nothing settled beneath the link
13+ */
14+export const handedTo = (
15+ trace: readonly TraceEntry<'tool.check'>[],
16+ at: number,
17+) => trace.slice(at + 1).find(link => link.returned !== undefined)?.returned
@@ -1,0 +1,6 @@
1+export * from './handed-to.js'
2+export * from './is-looser.js'
3+export * from './leniency'
4+export * from './tiers-holding-users'
5+
6+export * as default from '.'
@@ -1,0 +1,20 @@
1+import type { EventResult } from 'claude-code'
2+
3+import { LENIENCY } from './leniency'
4+
5+/**
6+ * Whether a link's own verdict is more permissive than the one handed up to
7+ * it; with nothing handed up (it never called `next`), than a deny.
8+ *
9+ * A link that settled on nothing (skipped, rejected) loosened nothing.
10+ *
11+ * @param own what the link settled on
12+ * @param handed what settled beneath it and was handed up, if anything did
13+ * @returns true when the link loosened the verdict
14+ */
15+export const isLooser = (
16+ own: EventResult<'tool.check'> | undefined,
17+ handed: EventResult<'tool.check'> | undefined,
18+) =>
19+ own !== undefined &&
20+ LENIENCY[own.decision] > LENIENCY[handed?.decision ?? 'deny']
@@ -1,0 +1,3 @@
1+export * from './leniency.js'
2+
3+export * as default from '.'
@@ -1,0 +1,5 @@
1+/**
2+ * How permissive each `tool.check` verdict is, the refusal lowest: a link
3+ * loosened a verdict when its own ranks above the one handed up to it.
4+ */
5+export const LENIENCY = Object.freeze({ deny: 0, ask: 1, allow: 2 })
@@ -1,0 +1,3 @@
1+export * from './tiers-holding-users.js'
2+
3+export * as default from '.'
@@ -1,0 +1,12 @@
1+/**
2+ * The tiers a trace entry can carry when a plugin a person installed ran in
3+ * it: `user`, and `prepend` beneath this plugin's own seat.
4+ *
5+ * The engine runs neighbouring plugins that share a worker as one batch and
6+ * lists the batch under its first member's tier, so an organization's
7+ * prepended plugin can stand for a batch that holds a person's.
8+ */
9+export const TIERS_HOLDING_USERS: readonly unknown[] = Object.freeze([
10+ 'user',
11+ 'prepend',
12+])
@@ -1,0 +1,3 @@
1+export type * from './rule-deny.js'
2+
3+export * as default from '.'
@@ -1,0 +1,9 @@
1+import type { EventResult } from 'claude-code'
2+
3+/**
4+ * A `tool.check` deny that names the settings rule behind it.
5+ */
6+export type RuleDeny = EventResult<'tool.check'> & {
7+ readonly decision: 'deny'
8+ readonly rule: string
9+}
@@ -1,0 +1,12 @@
1+import type { EventResult } from 'claude-code'
2+
3+/**
4+ * What the failure handler answers when a verdict was loosened, or never
5+ * reached, and no deny rule check vouches for it: absent counts as deny.
6+ */
7+export const UNCHECKED_DENY: EventResult<'tool.check'> = Object.freeze({
8+ decision: 'deny',
9+ reason:
10+ 'the deny rules in your settings could not be checked for this call, ' +
11+ 'so it is refused',
12+})
@@ -1,4 +1,4 @@
11 {
2- "description": "Security default: from the outermost seat, continues past the user tier on the organization's classic hooks, prompt content, settings and subjects, refuses a user-tier tool.register under an MCP allowlist, restores the organization's tools in tool.list, and refuses a user-tier hooks module at plugin.register while managed settings set its allowManagedModsOnly option",
2+ "description": "Security default: from the outermost seat, continues past the user tier on the organization's classic hooks, prompt content, settings and subjects, refuses a user-tier tool.register under an MCP allowlist, restores the organization's tools in tool.list, holds a settings deny rule 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",
33 "modules": ["./register.ts"]
44 }
@@ -1,4 +1,5 @@
11 export * from './admission-failure'
2+export * from './held-verdict'
23 export * from './managed-mods-only-refusal'
34 export * from './past-users'
45 export * from './policy'
@@ -1,0 +1,16 @@
1+import type { Settings } from 'claude-code'
2+
3+import { ownOption } from './own-option'
4+
5+/**
6+ * Whether deny rules hold over the plugins a person installs: they do unless
7+ * managed policy sets `allowModsToOverrideDenyRules` to the literal `true`.
8+ *
9+ * A value mistyped (`"true"`, `1`) loosens nothing. Only the policy source
10+ * is handed in, so a person's settings never reach this.
11+ *
12+ * @param policy the managed settings, as `$.settings.read` answers them
13+ * @returns false only when the organization let a person's plugins override
14+ */
15+export const denyRulesHold = (policy: Settings) =>
16+ ownOption(policy, 'allowModsToOverrideDenyRules') !== true
@@ -1,5 +1,6 @@
11 export * from './create-policy-memo'
22 export * from './decided-by-policy.js'
3+export * from './deny-rules-hold.js'
34 export * from './has-mcp-allowlist.js'
45 export * from './is-managed-mods-only.js'
56 export * from './managed-tools-restored'
@@ -1,6 +1,7 @@
11 import type { On } from 'claude-code'
22
33 import { admissionFailure } from './admission-failure'
4+import HeldVerdict from './held-verdict'
45 import { managedModsOnlyRefusal } from './managed-mods-only-refusal'
56 import { pastUsers } from './past-users'
67 import Policy from './policy'
@@ -18,6 +19,7 @@
1819 */
1920 export function register(on: On) {
2021 const readPolicy = Policy.createPolicyMemo(Policy.POLICY_MEMO_MS)
22+ const told = new Set<string>()
2123
2224 on('classic.*', ($, e, next) => next.to(e, 'append'))
2325
@@ -61,6 +63,45 @@
6163 ),
6264 )
6365
66+ on('tool.check', async ($, e, next) => {
67+ const answer = await next(e)
68+ const mods = HeldVerdict.loosenedByUsers(next.trace)
69+
70+ const shouldRecheck =
71+ answer.decision !== 'deny' &&
72+ mods.length > 0 &&
73+ (await Policy.decidedByPolicy(
74+ readPolicy(() => $.settings.read(Policy.SOURCE)),
75+ Policy.denyRulesHold,
76+ ))
77+
78+ if (!shouldRecheck) {
79+ return answer
80+ }
81+
82+ const held = await next.to(e, 'append')
83+
84+ if (!HeldVerdict.isRuleDeny(held)) {
85+ return answer
86+ }
87+
88+ for (const mod of mods.filter(name => !told.has(name))) {
89+ told.add(mod)
90+ $.ui.log(HeldVerdict.heldNotice(mod, e.tool, held.rule))
91+ }
92+
93+ return held
94+ }).catch(async ($, e, next) => {
95+ const last = await next(e).catch(() => undefined)
96+
97+ const shouldVouch = await Policy.decidedByPolicy(
98+ readPolicy(() => $.settings.read(Policy.SOURCE)),
99+ Policy.denyRulesHold,
100+ )
101+
102+ return shouldVouch ? HeldVerdict.caughtAnswer(last, next.trace) : last
103+ })
104+
64105 on('plugin.register', { tier: 'user' }, async ($, e, next) =>
65106 Policy.isManagedModsOnly(await $.settings.read(Policy.SOURCE))
66107 ? { refuse: managedModsOnlyRefusal(e.name) }
@@ -28,6 +28,7 @@
2828 export * from './signing.js'
2929 export * from './stripping.js'
3030 export * from './subjects-echoed.js'
31+export * from './tool-check'
3132 export * from './tool-described.js'
3233 export * from './tools.js'
3334 export * from './tools-command.js'
@@ -1,0 +1,6 @@
1+import type { EventResult } from 'claude-code'
2+
3+/**
4+ * What an auto-approve plugin answers every `tool.check`: allow.
5+ */
6+export const ALLOWED: EventResult<'tool.check'> = { decision: 'allow' }
@@ -1,0 +1,21 @@
1+import type { Plugin } from 'claude-code/testing'
2+
3+/**
4+ * A plugin that hears the verdict beneath it on `tool.check`, then allows
5+ * the call whatever it heard: the person's own by default.
6+ *
7+ * @param name the plugin's name
8+ * @param tier the tier it loads in
9+ * @returns the plugin
10+ */
11+export const allowing = (name: string, tier?: Plugin['tier']): Plugin => ({
12+ name,
13+ tier,
14+ register(on) {
15+ on('tool.check', async ($, e, next) => {
16+ await next(e)
17+
18+ return { decision: 'allow' }
19+ })
20+ },
21+})
@@ -1,0 +1,9 @@
1+import type { EventResult } from 'claude-code'
2+
3+/**
4+ * The engine's verdict when nothing allows or denies the call: ask.
5+ */
6+export const ASKED: EventResult<'tool.check'> = {
7+ decision: 'ask',
8+ reason: 'Bash needs approval',
9+}
@@ -1,0 +1,19 @@
1+import type { Plugin } from 'claude-code/testing'
2+
3+/**
4+ * A plugin the person installed that hears the verdict beneath it on
5+ * `tool.check`, then puts the call to the person whatever it heard.
6+ *
7+ * @param name the plugin's name
8+ * @returns the plugin
9+ */
10+export const asking = (name: string): Plugin => ({
11+ name,
12+ register(on) {
13+ on('tool.check', async ($, e, next) => {
14+ await next(e)
15+
16+ return { decision: 'ask', reason: 'Bash needs approval' }
17+ })
18+ },
19+})
@@ -1,0 +1,12 @@
1+import type { Plugin } from 'claude-code/testing'
2+
3+/**
4+ * A plugin the person installed that allows every `tool.check` without
5+ * calling `next`, so nothing beneath it runs.
6+ */
7+export const blindAllowing: Plugin = {
8+ name: 'blind',
9+ register(on) {
10+ on('tool.check', () => ({ decision: 'allow' }))
11+ },
12+}
@@ -1,0 +1,9 @@
1+import type { Args } from 'claude-code'
2+
3+/**
4+ * The question the engine puts to `tool.check`: may Bash run `echo x`.
5+ */
6+export const CHECKED: Args<'tool.check'> = {
7+ tool: 'Bash',
8+ input: { command: 'echo x' },
9+}
@@ -1,0 +1,21 @@
1+import type { EventResult, On } from 'claude-code'
2+
3+/**
4+ * Answers every `tool.check` beneath the plugins with the verdict given, as
5+ * the engine's own evaluation would, counting the evaluations.
6+ *
7+ * @param on the test's `on`
8+ * @param verdict what the engine's evaluation decides
9+ * @returns how many evaluations ran so far
10+ */
11+export function checksAnswered(on: On, verdict: EventResult<'tool.check'>) {
12+ let evaluations = 0
13+
14+ on('tool.check', () => {
15+ evaluations += 1
16+
17+ return verdict
18+ })
19+
20+ return () => evaluations
21+}
@@ -1,0 +1,16 @@
1+import type { Plugin } from 'claude-code/testing'
2+
3+/**
4+ * A plugin the person installed that answers ask over what it heard and
5+ * writes a rule of its own into the answer, as if settings held it.
6+ */
7+export const forging: Plugin = {
8+ name: 'forging',
9+ register(on) {
10+ on('tool.check', async ($, e, next) => {
11+ await next(e)
12+
13+ return { decision: 'ask', reason: 'fine by me', rule: 'Bash(ls *)' }
14+ })
15+ },
16+}
@@ -1,0 +1,17 @@
1+export * from './allowed.js'
2+export * from './allowing.js'
3+export * from './asked.js'
4+export * from './asking.js'
5+export * from './blind-allowing.js'
6+export * from './checked.js'
7+export * from './checks-answered.js'
8+export * from './forging.js'
9+export * from './link-of.js'
10+export * from './listening.js'
11+export * from './override-policy-of.js'
12+export * from './plain-deny.js'
13+export * from './rewriting.js'
14+export * from './rule-deny.js'
15+export * from './tightening.js'
16+
17+export * as default from '.'
@@ -1,0 +1,27 @@
1+import type { EventResult, Tier, TraceEntry } from 'claude-code'
2+
3+import { CHECKED } from './checked.js'
4+
5+/**
6+ * One settled link of a `tool.check` run as `next.trace` lists it: whose
7+ * hook, in which tier, and what it settled on (nothing when it was skipped).
8+ *
9+ * @param plugin the hook's plugin
10+ * @param tier the tier the engine pinned for it
11+ * @param returned what the link settled on
12+ * @returns the entry
13+ */
14+export const linkOf = (
15+ plugin: string,
16+ tier: Tier,
17+ returned?: EventResult<'tool.check'>,
18+): TraceEntry<'tool.check'> => ({
19+ index: 0,
20+ plugin,
21+ tier,
22+ event: 'tool.check',
23+ outcome: returned === undefined ? 'skipped' : 'returned',
24+ ms: 0,
25+ received: CHECKED,
26+ returned,
27+})
@@ -1,0 +1,17 @@
1+import type { Plugin } from 'claude-code/testing'
2+
3+/**
4+ * A plugin that hears every `tool.check` and hands up the verdict beneath it
5+ * as it is, as one that only logs would: the person's own by default.
6+ *
7+ * @param name the plugin's name
8+ * @param tier the tier it loads in
9+ * @returns the plugin
10+ */
11+export const listening = (name: string, tier?: Plugin['tier']): Plugin => ({
12+ name,
13+ tier,
14+ register(on) {
15+ on('tool.check', ($, e, next) => next(e))
16+ },
17+})
@@ -1,0 +1,16 @@
1+import type { Settings } from 'claude-code'
2+
3+/**
4+ * Settings that carry this plugin's own `allowModsToOverrideDenyRules`
5+ * option with the value given, where managed settings hold it.
6+ *
7+ * @param value what the option is set to
8+ * @returns the settings
9+ */
10+export const overridePolicyOf = (value: unknown): Settings => ({
11+ pluginConfigs: {
12+ 'cc-plugin-sec-default@builtin': {
13+ options: { allowModsToOverrideDenyRules: value },
14+ },
15+ },
16+})
@@ -1,0 +1,10 @@
1+import type { EventResult } from 'claude-code'
2+
3+/**
4+ * A deny that no settings rule decided (a settings hook's, a tool's own
5+ * check): it names no rule.
6+ */
7+export const PLAIN_DENY: EventResult<'tool.check'> = {
8+ decision: 'deny',
9+ reason: 'a PreToolUse hook refused it',
10+}
@@ -1,0 +1,16 @@
1+import type { Plugin } from 'claude-code/testing'
2+
3+/**
4+ * A plugin the person installed that asks beneath it about another command
5+ * than the one being decided, then allows the call.
6+ */
7+export const rewriting: Plugin = {
8+ name: 'rewriting',
9+ register(on) {
10+ on('tool.check', async ($, e, next) => {
11+ await next({ ...e, input: { command: 'ls' } })
12+
13+ return { decision: 'allow' }
14+ })
15+ },
16+}
@@ -1,0 +1,11 @@
1+import type { EventResult } from 'claude-code'
2+
3+/**
4+ * The engine's verdict when a deny rule in some settings file matched the
5+ * call: the deny, its sentence, and the rule as written.
6+ */
7+export const RULE_DENY: EventResult<'tool.check'> = {
8+ decision: 'deny',
9+ reason: 'Permission to use Bash has been denied.',
10+ rule: 'Bash(echo *)',
11+}
@@ -1,0 +1,16 @@
1+import type { Plugin } from 'claude-code/testing'
2+
3+/**
4+ * A plugin the person installed that hears the verdict beneath it on
5+ * `tool.check`, then refuses the call whatever it heard.
6+ */
7+export const tightening: Plugin = {
8+ name: 'strict',
9+ register(on) {
10+ on('tool.check', async ($, e, next) => {
11+ await next(e)
12+
13+ return { decision: 'deny', reason: 'a PreToolUse hook refused it' }
14+ })
15+ },
16+}
@@ -1,0 +1,80 @@
1+import { describe, expect, test, tier } from 'claude-code/testing'
2+
3+import Hooks from '../hooks'
4+import Fixtures from './fixtures'
5+
6+tier('prepend')
7+
8+describe('held-verdict', () => {
9+ test('a deny that names its rule is a rule deny; any other is not', () => {
10+ expect(
11+ [Fixtures.RULE_DENY, Fixtures.PLAIN_DENY, Fixtures.ASKED].map(
12+ Hooks.isRuleDeny,
13+ ),
14+ ).toEqual([true, false, false])
15+ })
16+
17+ test('a link of theirs is named when it answered looser than handed', () => {
18+ expect(
19+ Hooks.loosenedByUsers([
20+ Fixtures.linkOf('listening', 'user', Fixtures.ALLOWED),
21+ Fixtures.linkOf('easy', 'user', Fixtures.ALLOWED),
22+ Fixtures.linkOf('engine', 'core', Fixtures.ASKED),
23+ ]),
24+ ).toEqual(['easy'])
25+ })
26+
27+ test('one that never called next is measured against a deny', () => {
28+ expect(
29+ Hooks.loosenedByUsers([
30+ Fixtures.linkOf('listening', 'user', Fixtures.ALLOWED),
31+ Fixtures.linkOf('blind', 'user', Fixtures.ALLOWED),
32+ ]),
33+ ).toEqual(['blind'])
34+ })
35+
36+ test('a tightening, a skipped link and links beneath theirs are not', () => {
37+ expect(
38+ Hooks.loosenedByUsers([
39+ Fixtures.linkOf('strict', 'user', Fixtures.PLAIN_DENY),
40+ Fixtures.linkOf('passed-over', 'user'),
41+ Fixtures.linkOf('suite', 'append', Fixtures.ALLOWED),
42+ Fixtures.linkOf('bundled', 'builtin', Fixtures.ALLOWED),
43+ Fixtures.linkOf('engine', 'core', Fixtures.RULE_DENY),
44+ ]),
45+ ).toEqual([])
46+ })
47+
48+ test('a batch listed under prepend may hold a plugin of theirs', () => {
49+ expect(
50+ Hooks.loosenedByUsers([
51+ Fixtures.linkOf('audit+easy', 'prepend', Fixtures.ALLOWED),
52+ Fixtures.linkOf('engine', 'core', Fixtures.RULE_DENY),
53+ ]),
54+ ).toEqual(['audit+easy'])
55+ })
56+
57+ test('the handler keeps a deny, and a verdict none of theirs made', () => {
58+ const pastUsers = [
59+ Fixtures.linkOf('easy', 'user'),
60+ Fixtures.linkOf('engine', 'core', Fixtures.ASKED),
61+ ]
62+
63+ expect([
64+ Hooks.caughtAnswer(Fixtures.RULE_DENY, [
65+ Fixtures.linkOf('easy', 'user', Fixtures.ALLOWED),
66+ ]),
67+ Hooks.caughtAnswer(Fixtures.ASKED, pastUsers),
68+ ]).toEqual([Fixtures.RULE_DENY, Fixtures.ASKED])
69+ })
70+
71+ test('the handler refuses a verdict they loosened, and no verdict', () => {
72+ expect([
73+ Hooks.caughtAnswer(Fixtures.ALLOWED, [
74+ Fixtures.linkOf('easy', 'user', Fixtures.ALLOWED),
75+ Fixtures.linkOf('engine', 'core', Fixtures.ASKED),
76+ ]),
77+ Hooks.caughtAnswer(undefined, []),
78+ ]).toEqual([Hooks.UNCHECKED_DENY, Hooks.UNCHECKED_DENY])
79+ })
80+})
@@ -1,0 +1,24 @@
1+import { describe, expect, test, tier } from 'claude-code/testing'
2+
3+import Policy from '../../hooks/policy'
4+import Fixtures from '../fixtures'
5+
6+tier('prepend')
7+
8+describe('deny-rules-hold', () => {
9+ test('deny rules hold unless the option is the literal true', () => {
10+ expect(
11+ [true, 'true', 1, false, undefined].map(value =>
12+ Policy.denyRulesHold(Fixtures.overridePolicyOf(value)),
13+ ),
14+ ).toEqual([false, true, true, true, true])
15+ })
16+
17+ test('settings with no entry for this plugin hold them', () => {
18+ expect(
19+ [Fixtures.MANAGED_POLICY, {}, { pluginConfigs: 'x' }].map(
20+ Policy.denyRulesHold,
21+ ),
22+ ).toEqual([true, true, true])
23+ })
24+})
@@ -455,4 +455,366 @@
455455 })
456456 },
457457 )
458+
459+ test(
460+ 'a deny rule holds over an allow from a plugin the person installed',
461+ { plugins: [Fixtures.allowing('easy')] },
462+ async ($, on) => {
463+ on('settings.read', () => ({ value: Fixtures.MANAGED_POLICY }))
464+
465+ const lines = Fixtures.logged(on)
466+
467+ Fixtures.checksAnswered(on, Fixtures.RULE_DENY)
468+
469+ expect(await $.tool.check(Fixtures.CHECKED)).toEqual(Fixtures.RULE_DENY)
470+
471+ expect(lines).toEqual([
472+ `transcript: ${Hooks.heldNotice('easy', 'Bash', 'Bash(echo *)')}`,
473+ ])
474+ },
475+ )
476+
477+ test(
478+ 'two plugins of the person chained are each named once in a session',
479+ {
480+ plugins: [Fixtures.allowing('first'), Fixtures.asking('second')],
481+ },
482+ async ($, on) => {
483+ on('settings.read', () => ({ value: Fixtures.MANAGED_POLICY }))
484+
485+ const lines = Fixtures.logged(on)
486+
487+ Fixtures.checksAnswered(on, Fixtures.RULE_DENY)
488+
489+ expect(await $.tool.check(Fixtures.CHECKED)).toEqual(Fixtures.RULE_DENY)
490+ expect(await $.tool.check(Fixtures.CHECKED)).toEqual(Fixtures.RULE_DENY)
491+
492+ expect(lines.toSorted()).toEqual([
493+ `transcript: ${Hooks.heldNotice('first', 'Bash', 'Bash(echo *)')}`,
494+ `transcript: ${Hooks.heldNotice('second', 'Bash', 'Bash(echo *)')}`,
495+ ])
496+ },
497+ )
498+
499+ test(
500+ 'a deny rule turned into an ask by a plugin of the person holds too',
501+ { plugins: [Fixtures.asking('easy')] },
502+ async ($, on) => {
503+ on('settings.read', () => ({ value: Fixtures.MANAGED_POLICY }))
504+ Fixtures.logged(on)
505+ Fixtures.checksAnswered(on, Fixtures.RULE_DENY)
506+
507+ expect(await $.tool.check(Fixtures.CHECKED)).toEqual(Fixtures.RULE_DENY)
508+ },
509+ )
510+
511+ test(
512+ "an organization plugin's allow over a deny rule stands, above or below",
513+ {
514+ plugins: [
515+ Fixtures.allowing('suite', 'append'),
516+ Fixtures.allowing('easy'),
517+ ],
518+ },
519+ async ($, on) => {
520+ on('settings.read', () => ({ value: Fixtures.MANAGED_POLICY }))
521+
522+ const lines = Fixtures.logged(on)
523+
524+ Fixtures.checksAnswered(on, Fixtures.RULE_DENY)
525+
526+ expect(await $.tool.check(Fixtures.CHECKED)).toEqual(Fixtures.ALLOWED)
527+ expect(lines).toEqual([])
528+ },
529+ )
530+
531+ test(
532+ "a prepended organization plugin's allow over a deny rule stands",
533+ { plugins: [Fixtures.allowing('guard', 'prepend')] },
534+ async ($, on) => {
535+ on('settings.read', () => ({ value: Fixtures.MANAGED_POLICY }))
536+
537+ const lines = Fixtures.logged(on)
538+
539+ Fixtures.checksAnswered(on, Fixtures.RULE_DENY)
540+
541+ expect(await $.tool.check(Fixtures.CHECKED)).toEqual(Fixtures.ALLOWED)
542+ expect(lines).toEqual([])
543+ },
544+ )
545+
546+ test(
547+ "a built-in's allow over a deny rule stands",
548+ { plugins: [Fixtures.allowing('bundled', 'builtin')] },
549+ async ($, on) => {
550+ on('settings.read', () => ({ value: Fixtures.MANAGED_POLICY }))
551+
552+ const lines = Fixtures.logged(on)
553+
554+ Fixtures.checksAnswered(on, Fixtures.RULE_DENY)
555+
556+ expect(await $.tool.check(Fixtures.CHECKED)).toEqual(Fixtures.ALLOWED)
557+ expect(lines).toEqual([])
558+ },
559+ )
560+
561+ test(
562+ 'an ask a plugin of the person allows, no deny rule behind it, stands',
563+ { plugins: [Fixtures.allowing('easy')] },
564+ async ($, on) => {
565+ on('settings.read', () => ({ value: Fixtures.MANAGED_POLICY }))
566+
567+ const lines = Fixtures.logged(on)
568+
569+ Fixtures.checksAnswered(on, Fixtures.ASKED)
570+
571+ expect(await $.tool.check(Fixtures.CHECKED)).toEqual(Fixtures.ALLOWED)
572+ expect(lines).toEqual([])
573+ },
574+ )
575+
576+ test(
577+ 'a deny no rule decided is still theirs to answer over',
578+ { plugins: [Fixtures.allowing('easy')] },
579+ async ($, on) => {
580+ on('settings.read', () => ({ value: Fixtures.MANAGED_POLICY }))
581+
582+ const lines = Fixtures.logged(on)
583+
584+ Fixtures.checksAnswered(on, Fixtures.PLAIN_DENY)
585+
586+ expect(await $.tool.check(Fixtures.CHECKED)).toEqual(Fixtures.ALLOWED)
587+ expect(lines).toEqual([])
588+ },
589+ )
590+
591+ test(
592+ 'a plugin of the person that tightens is heard, with one evaluation',
593+ { plugins: [Fixtures.tightening] },
594+ async ($, on) => {
595+ const reads = Fixtures.policyReads(on, Fixtures.MANAGED_POLICY)
596+ const evaluations = Fixtures.checksAnswered(on, Fixtures.ASKED)
597+
598+ await $.tool.check(Fixtures.CHECKED)
599+
600+ const loaded = { reads: reads(), evaluations: evaluations() }
601+
602+ expect(await $.tool.check(Fixtures.CHECKED)).toEqual(Fixtures.PLAIN_DENY)
603+
604+ expect(
605+ {
606+ reads: reads() - loaded.reads,
607+ evaluations: evaluations() - loaded.evaluations,
608+ },
609+ 'once its plugins have loaded, a check reads no policy',
610+ ).toEqual({ reads: 0, evaluations: 1 })
611+ },
612+ )
613+
614+ test(
615+ "a deny rule holds when an organization's plugin listens above theirs",
616+ {
617+ plugins: [
618+ Fixtures.listening('audit', 'prepend'),
619+ Fixtures.allowing('easy'),
620+ ],
621+ },
622+ async ($, on) => {
623+ on('settings.read', () => ({ value: Fixtures.MANAGED_POLICY }))
624+
625+ const lines = Fixtures.logged(on)
626+
627+ Fixtures.checksAnswered(on, Fixtures.RULE_DENY)
628+
629+ expect(await $.tool.check(Fixtures.CHECKED)).toEqual(Fixtures.RULE_DENY)
630+
631+ expect(
632+ lines.map(line => line.includes('easy')),
633+ 'one line, naming the plugin of theirs, alone or in its batch',
634+ ).toEqual([true])
635+ },
636+ )
637+
638+ test(
639+ 'a plugin of the person that only listens costs no second evaluation',
640+ { plugins: [Fixtures.listening('listening')] },
641+ async ($, on) => {
642+ const reads = Fixtures.policyReads(on, Fixtures.MANAGED_POLICY)
643+ const evaluations = Fixtures.checksAnswered(on, Fixtures.ASKED)
644+
645+ await $.tool.check(Fixtures.CHECKED)
646+
647+ const loaded = { reads: reads(), evaluations: evaluations() }
648+
649+ expect(await $.tool.check(Fixtures.CHECKED)).toEqual(Fixtures.ASKED)
650+
651+ expect(
652+ {
653+ reads: reads() - loaded.reads,
654+ evaluations: evaluations() - loaded.evaluations,
655+ },
656+ 'once its plugins have loaded, a check reads no policy',
657+ ).toEqual({ reads: 0, evaluations: 1 })
658+ },
659+ )
660+
661+ test('with none of their plugins the verdict passes once', async ($, on) => {
662+ const reads = Fixtures.policyReads(on, Fixtures.MANAGED_POLICY)
663+ const evaluations = Fixtures.checksAnswered(on, Fixtures.RULE_DENY)
664+
665+ expect(await $.tool.check(Fixtures.CHECKED)).toEqual(Fixtures.RULE_DENY)
666+
667+ expect({ reads: reads(), evaluations: evaluations() }).toEqual({
668+ reads: 0,
669+ evaluations: 1,
670+ })
671+ })
672+
673+ test(
674+ 'an allow that never called next meets the deny rule all the same',
675+ { plugins: [Fixtures.blindAllowing] },
676+ async ($, on) => {
677+ on('settings.read', () => ({ value: Fixtures.MANAGED_POLICY }))
678+
679+ const lines = Fixtures.logged(on)
680+ const evaluations = Fixtures.checksAnswered(on, Fixtures.RULE_DENY)
681+
682+ expect(await $.tool.check(Fixtures.CHECKED)).toEqual(Fixtures.RULE_DENY)
683+
684+ expect({ lines, evaluations: evaluations() }).toEqual({
685+ lines: [
686+ `transcript: ${Hooks.heldNotice('blind', 'Bash', 'Bash(echo *)')}`,
687+ ],
688+ evaluations: 1,
689+ })
690+ },
691+ )
692+
693+ test(
694+ 'a rule a plugin of the person writes into its answer is never read',
695+ { plugins: [Fixtures.forging] },
696+ async ($, on) => {
697+ on('settings.read', () => ({ value: Fixtures.MANAGED_POLICY }))
698+
699+ const lines = Fixtures.logged(on)
700+
701+ Fixtures.checksAnswered(on, Fixtures.RULE_DENY)
702+
703+ expect(await $.tool.check(Fixtures.CHECKED)).toEqual(Fixtures.RULE_DENY)
704+
705+ expect(lines).toEqual([
706+ `transcript: ${Hooks.heldNotice('forging', 'Bash', 'Bash(echo *)')}`,
707+ ])
708+ },
709+ )
710+
711+ test(
712+ 'a plugin of the person asking about another command is left out',
713+ { plugins: [Fixtures.rewriting] },
714+ async ($, on) => {
715+ on('settings.read', () => ({ value: Fixtures.MANAGED_POLICY }))
716+ Fixtures.logged(on)
717+
718+ const asked: unknown[] = []
719+
720+ on('tool.check', ($, e) => {
721+ asked.push(e.input)
722+
723+ return Fixtures.RULE_DENY
724+ })
725+
726+ expect(await $.tool.check(Fixtures.CHECKED)).toEqual(Fixtures.RULE_DENY)
727+
728+ expect(
729+ asked,
730+ 'the rules were asked about the command that would run, only',
731+ ).toEqual([Fixtures.CHECKED.input])
732+ },
733+ )
734+
735+ test(
736+ 'managed settings may let the plugins a person installs override',
737+ { plugins: [Fixtures.allowing('easy')] },
738+ async ($, on) => {
739+ on('settings.read', () => ({ value: Fixtures.overridePolicyOf(true) }))
740+
741+ const lines = Fixtures.logged(on)
742+ const evaluations = Fixtures.checksAnswered(on, Fixtures.RULE_DENY)
743+
744+ expect(await $.tool.check(Fixtures.CHECKED)).toEqual(Fixtures.ALLOWED)
745+
746+ expect({ lines, evaluations: evaluations() }).toEqual({
747+ lines: [],
748+ evaluations: 1,
749+ })
750+ },
751+ )
752+
753+ test(
754+ "only managed settings are asked: a person's own cannot lift a deny rule",
755+ { plugins: [Fixtures.allowing('easy')] },
756+ async ($, on) => {
757+ Fixtures.policyBySource(
758+ on,
759+ Fixtures.MANAGED_POLICY,
760+ Fixtures.overridePolicyOf(true),
761+ )
762+
763+ Fixtures.logged(on)
764+ Fixtures.checksAnswered(on, Fixtures.RULE_DENY)
765+
766+ expect(await $.tool.check(Fixtures.CHECKED)).toEqual(Fixtures.RULE_DENY)
767+ },
768+ )
769+
770+ test(
771+ 'the option mistyped lifts nothing: only the literal true does',
772+ { plugins: [Fixtures.allowing('easy')] },
773+ async ($, on) => {
774+ on('settings.read', () => ({ value: Fixtures.overridePolicyOf('true') }))
775+ Fixtures.logged(on)
776+ Fixtures.checksAnswered(on, Fixtures.RULE_DENY)
777+
778+ expect(await $.tool.check(Fixtures.CHECKED)).toEqual(Fixtures.RULE_DENY)
779+ },
780+ )
781+
782+ test(
783+ 'with a policy that cannot be read the deny rule holds: fails closed',
784+ { plugins: [Fixtures.allowing('easy')] },
785+ async ($, on) => {
786+ const stopPolicy = Fixtures.policyUntilStopped(
787+ on,
788+ Fixtures.overridePolicyOf(true),
789+ 'managed settings unreadable',
790+ )
791+
792+ Fixtures.logged(on)
793+ Fixtures.checksAnswered(on, Fixtures.RULE_DENY)
794+ on('prompt.section', ($, e) => ({ text: e.text }))
795+
796+ await $.prompt.section(Fixtures.MEMORY)
797+ stopPolicy()
798+
799+ expect(
800+ await $.tool.check(Fixtures.CHECKED),
801+ 'the policy that read let plugins override; unread, the rule holds',
802+ ).toEqual(Fixtures.RULE_DENY)
803+ },
804+ )
805+
806+ test(
807+ "when the rules cannot be evaluated the call is refused: the hook's catch",
808+ { plugins: [Fixtures.blindAllowing] },
809+ async ($, on) => {
810+ on('settings.read', () => ({ value: Fixtures.MANAGED_POLICY }))
811+ Fixtures.logged(on)
812+
813+ on('tool.check', () => {
814+ throw new Error('the evaluation failed')
815+ })
816+
817+ expect(await $.tool.check(Fixtures.CHECKED)).toEqual(Hooks.UNCHECKED_DENY)
818+ },
819+ )
458820 })