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

Merge main into poteat/sec-default-deny-rules-holdBrowse files
poteat authored 24 days ago parents 2b52422 + 0d7f14d · commit 77b3c6198e37
17 changed files+416 -25compared with 2b52422
Mmods/sec-default/.claude-plugin/plugin.json
Mmods/sec-default/README.md
Amods/sec-default/hooks/admission-failure/admission-failure.ts
Amods/sec-default/hooks/admission-failure/index.ts
Mmods/sec-default/hooks/hooks.json
Mmods/sec-default/hooks/index.ts
Amods/sec-default/hooks/managed-mods-only-refusal/index.ts
Amods/sec-default/hooks/managed-mods-only-refusal/managed-mods-only-refusal.ts
Mmods/sec-default/hooks/policy/index.ts
Amods/sec-default/hooks/policy/is-managed-mods-only.ts
Mmods/sec-default/hooks/register.ts
Mmods/sec-default/tests/fixtures/index.ts
Amods/sec-default/tests/fixtures/managed-mods-only.ts
Amods/sec-default/tests/fixtures/mods-policy-of.ts
Amods/sec-default/tests/fixtures/policy-until-stopped.ts
Amods/sec-default/tests/policy/is-managed-mods-only.test.ts
Mmods/sec-default/tests/register.test.ts
@@ -1,7 +1,7 @@
11 {
22 "name": "sec-default",
33 "version": "0.1.0",
4- "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.",
4+ "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.",
55 "author": {
66 "name": "Anthropic"
77 }
@@ -11,11 +11,12 @@
1111 through untouched.
1212
1313 It has three moves and nothing else: continue past the user tier
14-(`next.to(e, "append")`), refuse a user-tier caller by name (`{ deny }`
15-when `next.origin.tier` is `user`), or pass (`next(e)`). A subject's
16-provenance is the event's pinned `e.provider`; policy is read through
17-`$.settings.read({ source: "policy" })`, one read serving a burst; both
18-fail closed, so an unreadable policy counts as a policy in force.
14+(`next.to(e, "append")`), refuse a user-tier caller or module by name
15+(`{ deny }` when `next.origin.tier` is `user`, `{ refuse }` when a module's
16+pinned `e.tier` is), or pass (`next(e)`). A subject's provenance is the
17+event's pinned `e.provider`; policy is read through
18+`$.settings.read({ source: "policy" })`, one read serving a burst of tool
19+calls; both fail closed, so an unreadable policy counts as a policy in force.
1920
2021 `hooks/register.ts` is the module; `hooks/policy/` reads the managed
2122 settings it decides by.
@@ -31,13 +32,69 @@
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. |
3334 | `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. |
35+| `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. |
3436 | 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
38+## Options an administrator sets
39+
40+Two, in managed settings, under this plugin's own `pluginConfigs` entry,
41+keyed by the id the CLI builds the plugin in under (only this spelling of the
42+id is read):
43+
44+```json
45+{
46+ "pluginConfigs": {
47+ "cc-plugin-sec-default@builtin": {
48+ "options": { "allowManagedModsOnly": true }
49+ }
50+ }
51+}
52+```
53+
54+`allowManagedModsOnly`: only the mods the organization deploys through
55+managed settings, and the ones built into Claude Code, load. A hooks module
56+a person installed, named with `--plugin-dir` or keeps in their mods folder
57+is refused whenever it loads or reloads (one already running when the option
58+is set keeps running until then), with one line that names it: `mods are
59+limited to your organization's by policy (allowManagedModsOnly); <plugin>
60+was not loaded`
61+(in the debug log, and on screen where the session hot-reloads the mod's
62+folder). A plain `-p` run has it in the debug log alone; the mod is still
63+not loaded. Settings hooks, status lines and `/goal` are not touched by it.
64+
65+- The decision reads the tier the CLI pins on the module and nothing the
66+ module says of itself: a person's copy carrying an organization mod's name
67+ is still in the `user` tier and is refused.
68+- Refused means nothing of the module joins: no hook, no tool, no command.
69+ Its top-level code has run once by then, in the closed context every hooks
70+ module is evaluated in, with no call on `$` served.
71+- Only managed settings are read (`$.settings.read({ source: "policy" })`):
72+ the same entry in a person's, a project's or a `--settings` file neither
73+ turns it on nor off. On unless absent or `false`, so a mistyped `"true"` or
74+ `1` still locks. A value the settings schema rejects (`null`, an object),
75+ here or in any other `pluginConfigs` entry, makes the CLI ignore the whole
76+ `pluginConfigs` key with a settings warning, and the option reads as unset.
77+- It fails closed: when the read of managed settings is refused (a hook beneath
78+ denies it) or this plugin's hook fails, its `.catch` refuses the module,
79+ with the same line, and names the failure in the debug log; so a policy
80+ that cannot be read keeps every person's mod out at load. Where this plugin
81+ is not seated there is no such rule, and mods load as they do without it.
82+- Where managed settings define `prependPlugins`, that list must name this
83+ plugin (next section) for the option to apply.
84+- `claude plugin test` is not covered: it runs a mod's tests in an engine of
85+ their own and loads nothing into a session.
86+
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+
3692 ## What it hooks
3793
3894 `classic.*`, `prompt.section`, `prompt.context`, `skill.prompt`,
3995 `attribution.text`, `settings.read`, `tool.describe`, `command.describe`,
40-`agent.offer`, `agent.spawn`, `tool.register`, `tool.list`, `tool.check`.
96+`agent.offer`, `agent.spawn`, `tool.register`, `tool.list`, `tool.check`,
97+`plugin.register`.
4198
4299 Hooking `tool.check` has a cost: the engine raises that event only when some
43100 loaded plugin hooks it, so where this plugin is seated every tool call now
@@ -46,9 +103,10 @@
46103
47104 ## What it calls on `$`
48105
49-`settings.read`, and `ui.log` for the one line a person reads when a deny
50-rule held over a plugin of theirs. It continues to the `append` tier with
51-`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.
52110
53111 ## Deny rules hold
54112
@@ -1,0 +1,13 @@
1+import type { HookFailure } from 'claude-code'
2+
3+/**
4+ * The debug line for a `plugin.register` hook of this plugin that failed:
5+ * the module it was judging, how it failed, and what the failure said.
6+ *
7+ * @param name the judged plugin's name, as its own manifest gives it
8+ * @param error why the hook failed, as its `.catch` reads it
9+ * @returns the line
10+ */
11+export const admissionFailure = (name: string, error: HookFailure) =>
12+ `plugin.register hook failed judging ${name} (${error.kind}): ` +
13+ (error.message ?? 'no message')
@@ -1,0 +1,3 @@
1+export * from './admission-failure.js'
2+
3+export * as default from '.'
@@ -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 holds a settings deny rule over a user-tier allow or ask on tool.check",
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,6 @@
1+export * from './admission-failure'
12 export * from './held-verdict'
3+export * from './managed-mods-only-refusal'
24 export * from './past-users'
35 export * from './policy'
46 export * from './register.js'
@@ -1,0 +1,3 @@
1+export * from './managed-mods-only-refusal.js'
2+
3+export * as default from '.'
@@ -1,0 +1,10 @@
1+/**
2+ * What a person reads when managed policy keeps a mod of theirs out: the
3+ * rule, the option that set it, and the mod that was not loaded.
4+ *
5+ * @param name the refused plugin's name, as its own manifest gives it
6+ * @returns the refusal line
7+ */
8+export const managedModsOnlyRefusal = (name: string) =>
9+ "mods are limited to your organization's by policy " +
10+ `(allowManagedModsOnly); ${name} was not loaded`
@@ -2,6 +2,7 @@
22 export * from './decided-by-policy.js'
33 export * from './deny-rules-hold.js'
44 export * from './has-mcp-allowlist.js'
5+export * from './is-managed-mods-only.js'
56 export * from './managed-tools-restored'
67 export * from './own-option'
78 export * from './policy-memo-ms.js'
@@ -1,0 +1,19 @@
1+import type { Settings } from 'claude-code'
2+
3+import { ownOption } from './own-option'
4+
5+/**
6+ * Whether managed policy limits mods to the organization's: this plugin's
7+ * own option `allowManagedModsOnly` in managed settings.
8+ *
9+ * On unless absent or `false`: a value mistyped (`"true"`, `1`) still
10+ * locks, as the CLI's own managed locks read.
11+ *
12+ * @param policy the managed settings, as `$.settings.read` answers them
13+ * @returns true when the person's own mods are not to load
14+ */
15+export function isManagedModsOnly(policy: Settings) {
16+ const value = ownOption(policy, 'allowManagedModsOnly')
17+
18+ return value !== undefined && value !== false
19+}
@@ -1,6 +1,8 @@
11 import type { On } from 'claude-code'
22
3+import { admissionFailure } from './admission-failure'
34 import HeldVerdict from './held-verdict'
5+import { managedModsOnlyRefusal } from './managed-mods-only-refusal'
46 import { pastUsers } from './past-users'
57 import Policy from './policy'
68 import { TOOL_REGISTER_REFUSAL } from './tool-register-refusal'
@@ -99,4 +101,14 @@
99101
100102 return shouldVouch ? HeldVerdict.caughtAnswer(last, next.trace) : last
101103 })
104+
105+ on('plugin.register', { tier: 'user' }, async ($, e, next) =>
106+ Policy.isManagedModsOnly(await $.settings.read(Policy.SOURCE))
107+ ? { refuse: managedModsOnlyRefusal(e.name) }
108+ : next(e),
109+ ).catch(($, e, next) => {
110+ $.ui.log(admissionFailure(e.name, next.error), { to: 'debug' })
111+
112+ return next.called ? next(e) : { refuse: managedModsOnlyRefusal(e.name) }
113+ })
102114 }
@@ -7,15 +7,18 @@
77 export * from './fullscreen.js'
88 export * from './listing.js'
99 export * from './logged.js'
10+export * from './managed-mods-only.js'
1011 export * from './managed-policy.js'
1112 export * from './marking.js'
1213 export * from './memory.js'
14+export * from './mods-policy-of.js'
1315 export * from './no-allowlist.js'
1416 export * from './odd-providers.js'
1517 export * from './org-providers.js'
1618 export * from './policy-by-source.js'
1719 export * from './policy-command.js'
1820 export * from './policy-reads.js'
21+export * from './policy-until-stopped.js'
1922 export * from './reading.js'
2023 export * from './registered-tool-of.js'
2124 export * from './registering.js'
@@ -1,0 +1,8 @@
1+import type { Settings } from 'claude-code'
2+
3+import { modsPolicyOf } from './mods-policy-of.js'
4+
5+/**
6+ * Managed settings that limit mods to the organization's.
7+ */
8+export const MANAGED_MODS_ONLY: Settings = modsPolicyOf(true)
@@ -1,0 +1,16 @@
1+import type { Settings } from 'claude-code'
2+
3+/**
4+ * Managed settings holding `allowManagedModsOnly` at the value given, where
5+ * an administrator writes it: the built-in's own `pluginConfigs` entry.
6+ *
7+ * @param value what the option is set to
8+ * @returns the managed settings
9+ */
10+export const modsPolicyOf = (value: boolean | number | string): Settings => ({
11+ pluginConfigs: {
12+ 'cc-plugin-sec-default@builtin': {
13+ options: { allowManagedModsOnly: value },
14+ },
15+ },
16+})
@@ -1,0 +1,23 @@
1+import type { On, Settings } from 'claude-code'
2+
3+/**
4+ * Answers every settings read beneath the plugins with the policy given
5+ * until the answer is called; every read after it is refused.
6+ *
7+ * So a test's plugins load under a policy that reads, and meet one that
8+ * does not.
9+ *
10+ * @param on the test's `on`
11+ * @param policy the managed settings in force while reads are answered
12+ * @param reason what a read is denied with once stopped
13+ * @returns `() => void`: from then on a read is denied
14+ */
15+export function policyUntilStopped(on: On, policy: Settings, reason: string) {
16+ let isStopped = false
17+
18+ on('settings.read', () => (isStopped ? { deny: reason } : { value: policy }))
19+
20+ return () => {
21+ isStopped = true
22+ }
23+}
@@ -1,0 +1,45 @@
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('is-managed-mods-only', () => {
9+ test('true locks; absent or false leaves mods as they are', () => {
10+ expect(
11+ [
12+ Fixtures.modsPolicyOf(true),
13+ Fixtures.modsPolicyOf(false),
14+ Fixtures.NO_ALLOWLIST,
15+ {},
16+ ].map(Policy.isManagedModsOnly),
17+ ).toEqual([true, false, false, false])
18+ })
19+
20+ test('a value mistyped still locks, as a managed lock reads', () => {
21+ expect(
22+ [
23+ Fixtures.modsPolicyOf('true'),
24+ Fixtures.modsPolicyOf('false'),
25+ Fixtures.modsPolicyOf(1),
26+ Fixtures.modsPolicyOf(0),
27+ ].map(Policy.isManagedModsOnly),
28+ ).toEqual([true, true, true, true])
29+ })
30+
31+ test('only its own pluginConfigs entry is read', () => {
32+ expect(
33+ [
34+ { allowManagedModsOnly: true },
35+ { pluginConfigs: { allowManagedModsOnly: true } },
36+ {
37+ pluginConfigs: {
38+ 'guard@corp-market': { options: { allowManagedModsOnly: true } },
39+ },
40+ },
41+ { pluginConfigs: 'cc-plugin-sec-default@builtin' },
42+ ].map(Policy.isManagedModsOnly),
43+ ).toEqual([false, false, false, false])
44+ })
45+})
@@ -67,10 +67,18 @@
6767 'a policy that cannot be read counts as one in force',
6868 { plugins: [Fixtures.registering('mine')] },
6969 async ($, on) => {
70- on('settings.read', () => ({ deny: 'managed settings unreadable' }))
70+ const stopPolicy = Fixtures.policyUntilStopped(
71+ on,
72+ Fixtures.NO_ALLOWLIST,
73+ 'managed settings unreadable',
74+ )
7175
7276 const registered = Fixtures.toolsRegistered(on)
7377
78+ on('prompt.section', ($, e) => ({ text: e.text }))
79+
80+ await $.prompt.section(Fixtures.MEMORY)
81+ stopPolicy()
7482 await $.session.start(Fixtures.SESSION)
7583
7684 expect(registered).toEqual([])
@@ -113,9 +121,18 @@
113121 "with no policy to read the organization's listing stands whole",
114122 { plugins: [Fixtures.relabeling, Fixtures.listing] },
115123 async ($, on) => {
116- on('settings.read', () => ({ deny: 'settings unreadable' }))
124+ const stopPolicy = Fixtures.policyUntilStopped(
125+ on,
126+ Fixtures.ALLOWLIST,
127+ 'settings unreadable',
128+ )
129+
130+ on('prompt.section', ($, e) => ({ text: e.text }))
117131 on('tool.list', () => ({ value: [...Fixtures.TOOLS] }))
118132
133+ await $.prompt.section(Fixtures.MEMORY)
134+ stopPolicy()
135+
119136 const { text } = await $.command.run(Fixtures.TOOLS_COMMAND)
120137
121138 expect(text?.split('\n')).toEqual([
@@ -129,6 +146,7 @@
129146 'a prompt section passes over the plugins the person installed',
130147 { plugins: [Fixtures.dropping, Fixtures.signing] },
131148 async ($, on) => {
149+ on('settings.read', () => ({ value: Fixtures.NO_ALLOWLIST }))
132150 on('prompt.section', ($, e) => ({ text: e.text }))
133151
134152 expect(await $.prompt.section(Fixtures.MEMORY)).toEqual({
@@ -170,6 +188,7 @@
170188 "an organization provider's subject passes over the user plugins",
171189 { plugins: [Fixtures.marking] },
172190 async ($, on) => {
191+ on('settings.read', () => ({ value: Fixtures.NO_ALLOWLIST }))
173192 Fixtures.subjectsEchoed(on)
174193
175194 for (const provider of Fixtures.ORG_PROVIDERS) {
@@ -228,6 +247,7 @@
228247 'an odd provider passes over the user plugins: it fails closed',
229248 { plugins: [Fixtures.marking] },
230249 async ($, on) => {
250+ on('settings.read', () => ({ value: Fixtures.NO_ALLOWLIST }))
231251 Fixtures.subjectsEchoed(on)
232252
233253 for (const provider of Fixtures.ODD_PROVIDERS) {
@@ -256,7 +276,7 @@
256276 await $.agent.spawn(Fixtures.agentSpawned(provider))
257277 }
258278
259- expect(reads()).toBe(0)
279+ expect(reads(), "one read: the person's plugin was admitted").toBe(1)
260280
261281 await Promise.all([
262282 $.command.run(Fixtures.TOOLS_COMMAND),
@@ -264,7 +284,7 @@
264284 $.command.run(Fixtures.TOOLS_COMMAND),
265285 ])
266286
267- expect(reads()).toBe(1)
287+ expect(reads()).toBe(2)
268288 },
269289 )
270290
@@ -308,6 +328,135 @@
308328 )
309329
310330 test(
331+ "under allowManagedModsOnly a person's mod is kept out, told why",
332+ { plugins: [Fixtures.registering('mine')] },
333+ async ($, on) => {
334+ on('settings.read', () => ({ value: Fixtures.MANAGED_MODS_ONLY }))
335+
336+ const registered = Fixtures.toolsRegistered(on)
337+
338+ await expect($.session.start(Fixtures.SESSION)).rejects.toThrow(
339+ Hooks.managedModsOnlyRefusal('mine'),
340+ )
341+
342+ expect(registered, 'no hook of it ever ran').toEqual([])
343+ },
344+ )
345+
346+ test(
347+ "under it the organization's mods and the built-ins load",
348+ {
349+ plugins: [
350+ Fixtures.registering('suite', 'prepend'),
351+ Fixtures.registering('after', 'append'),
352+ Fixtures.registering('bundled', 'builtin'),
353+ ],
354+ },
355+ async ($, on) => {
356+ on('settings.read', () => ({ value: Fixtures.MANAGED_MODS_ONLY }))
357+
358+ const registered = Fixtures.toolsRegistered(on)
359+
360+ await $.session.start(Fixtures.SESSION)
361+
362+ expect(registered).toEqual(['suite', 'after', 'bundled'])
363+ },
364+ )
365+
366+ test(
367+ 'set to false, a mod the person installed loads as before',
368+ { plugins: [Fixtures.registering('mine')] },
369+ async ($, on) => {
370+ on('settings.read', () => ({ value: Fixtures.modsPolicyOf(false) }))
371+
372+ const registered = Fixtures.toolsRegistered(on)
373+
374+ await $.session.start(Fixtures.SESSION)
375+
376+ expect(registered).toEqual(['mine'])
377+ },
378+ )
379+
380+ test(
381+ "a person's copy under an organization mod's name is still theirs",
382+ { plugins: [Fixtures.registering('suite')] },
383+ async ($, on) => {
384+ on('settings.read', () => ({
385+ value: { ...Fixtures.MANAGED_POLICY, ...Fixtures.MANAGED_MODS_ONLY },
386+ }))
387+
388+ const registered = Fixtures.toolsRegistered(on)
389+
390+ await expect($.session.start(Fixtures.SESSION)).rejects.toThrow(
391+ Hooks.managedModsOnlyRefusal('suite'),
392+ )
393+
394+ expect(registered).toEqual([])
395+ },
396+ )
397+
398+ test(
399+ "only managed settings are asked: a person's own cannot loosen it",
400+ { plugins: [Fixtures.registering('mine')] },
401+ async ($, on) => {
402+ Fixtures.policyBySource(
403+ on,
404+ Fixtures.MANAGED_MODS_ONLY,
405+ Fixtures.modsPolicyOf(false),
406+ )
407+
408+ const registered = Fixtures.toolsRegistered(on)
409+
410+ await expect($.session.start(Fixtures.SESSION)).rejects.toThrow(
411+ Hooks.managedModsOnlyRefusal('mine'),
412+ )
413+
414+ expect(registered).toEqual([])
415+ },
416+ )
417+
418+ test(
419+ "nor turn it on: set only in a person's settings, it is not read",
420+ { plugins: [Fixtures.registering('mine')] },
421+ async ($, on) => {
422+ Fixtures.policyBySource(
423+ on,
424+ Fixtures.NO_ALLOWLIST,
425+ Fixtures.MANAGED_MODS_ONLY,
426+ )
427+
428+ const registered = Fixtures.toolsRegistered(on)
429+
430+ await $.session.start(Fixtures.SESSION)
431+
432+ expect(registered).toEqual(['mine'])
433+ },
434+ )
435+
436+ test(
437+ "a policy that cannot be read keeps a person's mod out: the hook's catch",
438+ { plugins: [Fixtures.registering('mine')] },
439+ async ($, on) => {
440+ on('settings.read', () => ({ deny: 'managed settings unreadable' }))
441+
442+ const lines = Fixtures.logged(on)
443+ const registered = Fixtures.toolsRegistered(on)
444+
445+ await expect($.session.start(Fixtures.SESSION)).rejects.toThrow(
446+ Hooks.managedModsOnlyRefusal('mine'),
447+ )
448+
449+ expect({ registered, lines }).toEqual({
450+ registered: [],
451+ lines: [
452+ 'debug: plugin.register hook failed judging mine (throw): ' +
453+ 'sec-default: $.settings.read: managed settings unreadable',
454+ ],
455+ })
456+ },
457+ )
458+
459+ test(
311460 'a deny rule holds over an allow from a plugin the person installed',
312461 { plugins: [Fixtures.allowing('easy')] },
313462 async ($, on) => {
@@ -446,12 +595,19 @@
446595 const reads = Fixtures.policyReads(on, Fixtures.MANAGED_POLICY)
447596 const evaluations = Fixtures.checksAnswered(on, Fixtures.ASKED)
448597
598+ await $.tool.check(Fixtures.CHECKED)
599+
600+ const loaded = { reads: reads(), evaluations: evaluations() }
601+
449602 expect(await $.tool.check(Fixtures.CHECKED)).toEqual(Fixtures.PLAIN_DENY)
450603
451- expect({ reads: reads(), evaluations: evaluations() }).toEqual({
452- reads: 0,
453- evaluations: 1,
454- })
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 })
455611 },
456612 )
457613
@@ -486,12 +642,19 @@
486642 const reads = Fixtures.policyReads(on, Fixtures.MANAGED_POLICY)
487643 const evaluations = Fixtures.checksAnswered(on, Fixtures.ASKED)
488644
645+ await $.tool.check(Fixtures.CHECKED)
646+
647+ const loaded = { reads: reads(), evaluations: evaluations() }
648+
489649 expect(await $.tool.check(Fixtures.CHECKED)).toEqual(Fixtures.ASKED)
490650
491- expect({ reads: reads(), evaluations: evaluations() }).toEqual({
492- reads: 0,
493- evaluations: 1,
494- })
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 })
495658 },
496659 )
497660
@@ -620,11 +783,23 @@
620783 'with a policy that cannot be read the deny rule holds: fails closed',
621784 { plugins: [Fixtures.allowing('easy')] },
622785 async ($, on) => {
623- on('settings.read', () => ({ deny: 'managed settings unreadable' }))
786+ const stopPolicy = Fixtures.policyUntilStopped(
787+ on,
788+ Fixtures.overridePolicyOf(true),
789+ 'managed settings unreadable',
790+ )
791+
624792 Fixtures.logged(on)
625793 Fixtures.checksAnswered(on, Fixtures.RULE_DENY)
794+ on('prompt.section', ($, e) => ({ text: e.text }))
626795
627- expect(await $.tool.check(Fixtures.CHECKED)).toEqual(Fixtures.RULE_DENY)
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)
628803 },
629804 )
630805