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 person's plugin may tighten, never loosen, what holds over itBrowse files
Where this plugin is seated, a plugin a person installs no longer answers more permissively than any deny on tool.check, an ask a settings rule or a classic hook decided, or a variable managed settings set in env. The lines an organization's plugins log, this one's included, continue past the user tier.
Alice Poteat authored 20 days ago parent 1c229fc · commit 1169a7fdf26a
44 changed files+964 -95compared with 1c229fc
Mmods/README.md
Mmods/sec-default/.claude-plugin/plugin.json
Mmods/sec-default/README.md
Mmods/sec-default/hooks/held-verdict/caught-answer.ts
Amods/sec-default/hooks/held-verdict/held-kind.ts
Mmods/sec-default/hooks/held-verdict/held-notice.ts
Mmods/sec-default/hooks/held-verdict/index.ts
Amods/sec-default/hooks/held-verdict/verdicts/holds-over.ts
Mmods/sec-default/hooks/held-verdict/verdicts/index.ts
Dmods/sec-default/hooks/held-verdict/verdicts/is-rule-deny.ts
Dmods/sec-default/hooks/held-verdict/verdicts/types/index.ts
Dmods/sec-default/hooks/held-verdict/verdicts/types/rule-deny.ts
Mmods/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/pinned-variable-refusal/index.ts
Amods/sec-default/hooks/pinned-variable-refusal/pinned-variable-refusal.ts
Mmods/sec-default/hooks/policy/deny-rules-hold.ts
Mmods/sec-default/hooks/policy/index.ts
Amods/sec-default/hooks/policy/is-pinned-variable.ts
Mmods/sec-default/hooks/register.ts
Mmods/sec-default/tests/fixtures/index.ts
Amods/sec-default/tests/fixtures/logging.ts
Amods/sec-default/tests/fixtures/lower-case-pinning-policy.ts
Amods/sec-default/tests/fixtures/pinning-policy.ts
Amods/sec-default/tests/fixtures/revaluing.ts
Amods/sec-default/tests/fixtures/setting-lower-case.ts
Amods/sec-default/tests/fixtures/setting-other.ts
Amods/sec-default/tests/fixtures/setting-proxy.ts
Amods/sec-default/tests/fixtures/tool-check/checks-answered-in-turn.ts
Amods/sec-default/tests/fixtures/tool-check/hook-ask.ts
Amods/sec-default/tests/fixtures/tool-check/hook-deny.ts
Mmods/sec-default/tests/fixtures/tool-check/index.ts
Amods/sec-default/tests/fixtures/tool-check/muting.ts
Mmods/sec-default/tests/fixtures/tool-check/plain-deny.ts
Amods/sec-default/tests/fixtures/tool-check/rule-ask.ts
Amods/sec-default/tests/fixtures/tool-check/sneaking.ts
Mmods/sec-default/tests/fixtures/tool-check/tightening.ts
Amods/sec-default/tests/fixtures/unsetting-proxy.ts
Amods/sec-default/tests/fixtures/variables-set.ts
Mmods/sec-default/tests/held-verdict.test.ts
Amods/sec-default/tests/policy/is-pinned-variable.test.ts
Mmods/sec-default/tests/register.test.ts
Mmods/types/claude-code.d.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, 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 |
10+| [`sec-default`](sec-default) | Keeps an organization's classic hooks, prompt content, managed settings, tool policy, permission rules and pinned environment out of reach of the plugins a person installs; adds no policy of its own. | Outermost, on a machine with managed settings or for a Team or Enterprise organization, unless managed `prependPlugins` says otherwise |
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 |
@@ -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; one managed option, allowManagedModsOnly, limits mods to the organization's.",
4+ "description": "Security default for organizations: seated outermost, it keeps the organization's classic hooks, prompt content, settings, tool policy and pinned environment out of reach of the plugins a person installs, and adds no policy of its own; one managed option, allowManagedModsOnly, limits mods to the organization's.",
55 "author": {
66 "name": "Anthropic"
77 }
@@ -32,9 +32,11 @@
3232 | `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. |
3333 | `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. |
3434 | `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 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+| `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+| `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+| `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. |
3638 | `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. |
37-| 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`. |
39+| everything else | Passes: `prompt.submit`, `turn.*`, `tool.call`, `command.run`, `command.register`, `session.*`, `ui.*` but `ui.log`, `fs.*`, `http.fetch`, `process.run`, `env.get`, `store.*`, `clock.*`, `model.*`, `mcp.call`, `audio.*`, `agent.list`, `engine.create`. |
3840
3941 ## Options an administrator sets
4042
@@ -87,25 +89,28 @@
8789
8890 `allowModsToOverrideDenyRules`: the plugins a person installs may answer
8991 over a settings deny rule on `tool.check`, as they could before this plugin
90-held deny rules. Off unless it is the literal `true`; an option that reads
91-as unset leaves deny rules holding. See [Deny rules hold](#deny-rules-hold).
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
95+[Deny rules hold](#deny-rules-hold).
9296
9397 ## What it hooks
9498
9599 `classic.*`, `prompt.section`, `prompt.context`, `prompt.compose`, `skill.prompt`,
96100 `attribution.text`, `settings.read`, `tool.describe`, `command.describe`,
97101 `agent.offer`, `agent.spawn`, `tool.register`, `tool.list`, `tool.check`,
98-`plugin.register`.
102+`ui.log`, `env.set`, `plugin.register`.
99103
100104 Hooking `tool.check` has a cost: the engine raises that event only when some
101105 loaded plugin hooks it, so where this plugin is seated every tool call now
102106 runs the `tool.check` chain, where before only a session with such a plugin
103-did.
107+did. So has hooking `ui.log` and `env.set`: each such call of any plugin's now
108+runs a chain, and an `env.set` waits on the policy read.
104109
105110 ## What it calls on `$`
106111
107112 `settings.read`, and `ui.log`: to the debug log, and for the one line a
108-person reads when a deny rule held over a plugin of theirs. It continues to
113+person reads when something held over a plugin of theirs. It continues to
109114 the `append` tier with `next.to`, which only a plugin in a managed tier may
110115 do.
111116
@@ -113,8 +118,8 @@
113118
114119 On `tool.check` any hook may answer any verdict, so a plugin a person
115120 installs to stop the permission prompts (`() => ({ decision: "allow" })`)
116-would also lift a deny rule, a managed one included. Where this plugin is
117-seated it does not:
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:
118123
119124 - The hook first runs the chain as it is. If the answer is a deny, or no
120125 link that may hold a person's plugin answered more permissively than the
@@ -131,34 +136,54 @@
131136 plugin, so neither the decision nor the rule it names can have been
132137 rewritten or erased, and a plugin that answered without calling `next`
133138 changes nothing: the rules are evaluated in this run. The two runs differ
134- by the user tier alone, so a deny here that names its rule is a deny rule
135- the user tier loosened, and it is returned in place of the chain's answer.
136-- Any deny rule counts, whatever settings file it came from: a verdict
137- carries the rule as written, never where it was read from. A deny that
138- names no rule (a settings hook's, a tool's own check) is not held.
139+ by the user tier alone, so a verdict here that is stricter than the chain's
140+ 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.
139148 - An organization's plugin (prepend or append) or a built-in that allows
140149 over a deny rule takes part in both runs, so its answer stands (a prepended
141150 one that loosens is what brings the second run about, so its hooks run
142- twice on such a call). An ask
143- that a person's plugin turns into an allow, with no deny rule behind it,
151+ 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,
144153 stands: that is what such a plugin is for.
145154 - `tool.check` pins the question (`tool`, `input`, `tool_use_id`), so no hook
146155 can have the rules evaluated on one command and another run; a rewrite
147156 belongs to `tool.call`, which runs before any of this.
148-- The person is told once for each name in a session, in the transcript
149- and the debug log: `<plugin> tried to lift a deny rule in your settings
150- from a <tool> call (<rule>); the deny rule holds over the plugins you
151- install (allowModsToOverrideDenyRules)`. Plugins the engine ran as one
157+- The person is told once for each name and kind in a session, in the
158+ transcript and the debug log: `<plugin> tried to lift a deny rule in your
159+ settings from a <tool> call (<rule>); the deny rule holds over the plugins
160+ 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
152164 batch are named together, as it names them (`audit+easy`). A plain `-p`
153- run has it in the debug log alone; the call is still denied with the
154- rule's own message.
165+ run has it in the debug log alone; the call is still denied, or asked
166+ about, with its own message. The line goes past the user tier (the `ui.log`
167+ row): a hook of theirs that heard it would run inside this plugin's own
168+ `tool.check` hook, and the engine leaves a hook out of whatever is raised
169+ from inside it.
170+- What this does not reach: a held ask is still an ask, so whatever answers
171+ asks for the person answers it, a classic `PermissionRequest` hook of
172+ theirs included (`allowManagedHooksOnly` is the control for those). And a
173+ command hook decides only if its command runs: it is spawned in the
174+ session's environment and a hook that fails to run decides nothing, so a
175+ variable its spawn depends on (`PATH`, `CLAUDE_CODE_SHELL_PREFIX`) that the
176+ organization does not set in managed `env` is a person's to change, by their
177+ own settings as by a plugin's `$.env.set`. Set in managed `env`, it is
178+ pinned (the `env.set` row).
155179 - If the hook itself fails, its `.catch` answers from the one run it can
156180 read: a deny stands; a verdict no plugin of the person's loosened stands;
157- one they loosened, or a run that rejected, is refused, since the deny
158- rules were never consulted.
181+ one they loosened, or a run that rejected, is refused, since the rules
182+ and hooks were never consulted.
159183
160184 An organization that wants the plugins its people install to override deny
161-rules says so in managed settings, under this plugin's own options:
185+rules, and all else that holds with them, says so in managed settings, under
186+this plugin's own options:
162187
163188 ```json
164189 {
@@ -173,7 +198,7 @@
173198 Only the managed source is read (`$.settings.read({ source: "policy" })`),
174199 so the same key in a person's, a project's or a local settings file, or in
175200 `--settings`, is never consulted; only the literal `true` counts, and a
176-policy that cannot be read leaves deny rules holding.
201+policy that cannot be read leaves them all holding.
177202
178203 ## Where it is seated
179204
@@ -7,7 +7,7 @@
77 * can read, the failed hook's last: that run's verdict, or a refusal.
88 *
99 * 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.
10+ * plugin loosened. A loosened one, or none at all, met no rule or hook.
1111 *
1212 * @param last what that run settled on; undefined when it rejected
1313 * @param trace that run's `next.trace`
@@ -1,0 +1,19 @@
1+import type { EventResult } from 'claude-code'
2+
3+/**
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`.
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+ * @param held the verdict that holds
11+ * @returns the kind
12+ */
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+}
@@ -1,15 +1,32 @@
1+import type { EventResult } from 'claude-code'
2+
3+import { heldKind } from './held-kind.js'
4+
15 /**
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.
6+ * What a person reads, once for each plugin and kind in a session, when a
7+ * plugin they installed answered more permissively than what holds over it.
48 *
59 * It names the option an administrator sets to let such plugins override.
610 *
711 * @param plugin the plugin's name, or its batch's, as the trace names it
812 * @param tool the tool the call named
9- * @param rule the deny rule that decided, as written
13+ * @param held the verdict that holds, naming the rule behind it if one is
1014 * @returns the line
1115 */
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)'
16+export function heldNotice(
17+ plugin: string,
18+ tool: string,
19+ held: EventResult<'tool.check'>,
20+) {
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})`
27+
28+ return (
29+ `${plugin} tried to lift ${lifted}; the ${kind} holds over the plugins ` +
30+ 'you install (allowModsToOverrideDenyRules)'
31+ )
32+}
@@ -1,4 +1,5 @@
11 export * from './caught-answer.js'
2+export * from './held-kind.js'
23 export * from './held-notice.js'
34 export * from './verdicts'
45
@@ -1,0 +1,24 @@
1+import type { EventResult } from 'claude-code'
2+
3+import Ranking from './ranking'
4+
5+/**
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.
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.
12+ *
13+ * @param held what the run past the user tier settled on
14+ * @param answer what the whole chain settled on
15+ * @returns true when the plugins a person installs may not have loosened it
16+ */
17+export const holdsOver = (
18+ held: EventResult<'tool.check'>,
19+ answer: EventResult<'tool.check'>,
20+) =>
21+ Ranking.isLooser(answer, held) &&
22+ (held.decision === 'deny' ||
23+ held.rule !== undefined ||
24+ held.hook !== undefined)
@@ -1,7 +1,6 @@
1-export * from './is-rule-deny.js'
1+export * from './holds-over.js'
22 export * from './loosened-by-users.js'
33 export * from './ranking'
4-export * from './types'
54 export * from './unchecked-deny.js'
65
76 export * as default from '.'
Dmods/sec-default/hooks/held-verdict/verdicts/is-rule-deny.ts+0 -18
@@ -1,18 +1,0 @@
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
Dmods/sec-default/hooks/held-verdict/verdicts/types/index.ts+0 -3
@@ -1,3 +1,0 @@
1-export type * from './rule-deny.js'
2-
3-export * as default from '.'
Dmods/sec-default/hooks/held-verdict/verdicts/types/rule-deny.ts+0 -9
@@ -1,9 +1,0 @@
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-}
@@ -2,11 +2,11 @@
22
33 /**
44 * 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.
5+ * reached, and no check of the rules and hooks vouches for it: a deny.
66 */
77 export const UNCHECKED_DENY: EventResult<'tool.check'> = Object.freeze({
88 decision: 'deny',
99 reason:
10- 'the deny rules in your settings could not be checked for this call, ' +
11- 'so it is refused',
10+ 'the rules and hooks in your settings could not be checked for this ' +
11+ 'call, so it is refused',
1212 })
@@ -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, 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",
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",
33 "modules": ["./register.ts"]
44 }
@@ -2,6 +2,7 @@
22 export * from './held-verdict'
33 export * from './managed-mods-only-refusal'
44 export * from './past-users'
5+export * from './pinned-variable-refusal'
56 export * from './policy'
67 export * from './register.js'
78 export * from './tool-register-refusal'
@@ -1,0 +1,3 @@
1+export * from './pinned-variable-refusal.js'
2+
3+export * as default from '.'
@@ -1,0 +1,12 @@
1+/**
2+ * Why a plugin a person installed may not set or unset an environment
3+ * variable their organization pins: the setting, then the rule.
4+ *
5+ * Said too when the policy cannot be read and every variable counts as
6+ * pinned, so it does not say who set this one.
7+ *
8+ * @param name the variable, as the plugin named it
9+ * @returns the refusal
10+ */
11+export const pinnedVariableRefusal = (name: string) =>
12+ `env (managed): ${name} is not for plugins outside policy to change`
@@ -6,8 +6,9 @@
66 * Whether deny rules hold over the plugins a person installs: they do unless
77 * managed policy sets `allowModsToOverrideDenyRules` to the literal `true`.
88 *
9- * A value mistyped (`"true"`, `1`) loosens nothing. Only the policy source
10- * is handed in, so a person's settings never reach this.
9+ * Ask rules and classic hooks' answers hold or not with them. A value mistyped
10+ * (`"true"`, `1`) loosens nothing. Only the policy source is handed in, so a
11+ * person's settings never reach this.
1112 *
1213 * @param policy the managed settings, as `$.settings.read` answers them
1314 * @returns false only when the organization let a person's plugins override
@@ -3,6 +3,7 @@
33 export * from './deny-rules-hold.js'
44 export * from './has-mcp-allowlist.js'
55 export * from './is-managed-mods-only.js'
6+export * from './is-pinned-variable.js'
67 export * from './managed-tools-restored'
78 export * from './own-option'
89 export * from './policy-memo-ms.js'
@@ -1,0 +1,14 @@
1+import type { Settings } from 'claude-code'
2+
3+/**
4+ * Whether managed policy pins an environment variable: its `env` sets the
5+ * name, in any case (some systems read `Path` and `PATH` as one variable).
6+ *
7+ * @param policy the managed settings, as `$.settings.read` answers them
8+ * @param name the variable a plugin would set or unset
9+ * @returns true when the organization set it
10+ */
11+export const isPinnedVariable = (policy: Settings, name: string) =>
12+ Object.keys(policy.env ?? {}).some(
13+ pinned => pinned.toUpperCase() === name.toUpperCase(),
14+ )
@@ -4,6 +4,7 @@
44 import HeldVerdict from './held-verdict'
55 import { managedModsOnlyRefusal } from './managed-mods-only-refusal'
66 import { pastUsers } from './past-users'
7+import { pinnedVariableRefusal } from './pinned-variable-refusal'
78 import Policy from './policy'
89 import { TOOL_REGISTER_REFUSAL } from './tool-register-refusal'
910
@@ -82,13 +83,15 @@
8283
8384 const held = await next.to(e, 'append')
8485
85- if (!HeldVerdict.isRuleDeny(held)) {
86+ if (!HeldVerdict.holdsOver(held, answer)) {
8687 return answer
8788 }
8889
89- for (const mod of mods.filter(name => !told.has(name))) {
90- told.add(mod)
91- $.ui.log(HeldVerdict.heldNotice(mod, e.tool, held.rule))
90+ const kind = HeldVerdict.heldKind(held)
91+
92+ for (const mod of mods.filter(name => !told.has(`${name} ${kind}`))) {
93+ told.add(`${mod} ${kind}`)
94+ $.ui.log(HeldVerdict.heldNotice(mod, e.tool, held))
9295 }
9396
9497 return held
@@ -103,6 +106,40 @@
103106 return shouldVouch ? HeldVerdict.caughtAnswer(last, next.trace) : last
104107 })
105108
109+ on('ui.log', ($, e, next) => {
110+ const isOrgs =
111+ next.origin.tier === 'prepend' || next.origin.tier === 'append'
112+
113+ return isOrgs ? next.to(e, 'append') : next(e)
114+ })
115+
116+ on('env.set', async ($, e, next) => {
117+ const isPinned = await Policy.decidedByPolicy(
118+ readPolicy(() => $.settings.read(Policy.SOURCE)),
119+ policy => Policy.isPinnedVariable(policy, e.name),
120+ )
121+
122+ if (!isPinned) {
123+ return next(e)
124+ }
125+
126+ const isTheirs = next.origin.tier === 'user'
127+
128+ return isTheirs
129+ ? { deny: pinnedVariableRefusal(e.name) }
130+ : next.to(e, 'append')
131+ }).catch(($, e, next) => {
132+ if (next.called) {
133+ return next(e)
134+ }
135+
136+ const isTheirs = next.origin.tier === 'user'
137+
138+ return isTheirs
139+ ? { deny: pinnedVariableRefusal(e.name) }
140+ : next.to(e, 'append')
141+ })
142+
106143 on('plugin.register', { tier: 'user' }, async ($, e, next) =>
107144 Policy.isManagedModsOnly(await $.settings.read(Policy.SOURCE))
108145 ? { refuse: managedModsOnlyRefusal(e.name) }
@@ -10,6 +10,8 @@
1010 export * from './heading.js'
1111 export * from './listing.js'
1212 export * from './logged.js'
13+export * from './logging.js'
14+export * from './lower-case-pinning-policy.js'
1315 export * from './managed-mods-only.js'
1416 export * from './managed-policy.js'
1517 export * from './marking.js'
@@ -18,6 +20,7 @@
1820 export * from './no-allowlist.js'
1921 export * from './odd-providers.js'
2022 export * from './org-providers.js'
23+export * from './pinning-policy.js'
2124 export * from './policy-by-source.js'
2225 export * from './policy-command.js'
2326 export * from './policy-reads.js'
@@ -26,9 +29,13 @@
2629 export * from './registered-tool-of.js'
2730 export * from './registering.js'
2831 export * from './relabeling.js'
32+export * from './revaluing.js'
2933 export * from './rewording.js'
3034 export * from './server-policy.js'
3135 export * from './session.js'
36+export * from './setting-lower-case.js'
37+export * from './setting-other.js'
38+export * from './setting-proxy.js'
3239 export * from './signing.js'
3340 export * from './stripping.js'
3441 export * from './subjects-echoed.js'
@@ -37,6 +44,8 @@
3744 export * from './tools.js'
3845 export * from './tools-command.js'
3946 export * from './tools-registered.js'
47+export * from './unsetting-proxy.js'
4048 export * from './user-reachable-providers.js'
49+export * from './variables-set.js'
4150
4251 export * as default from '.'
@@ -1,0 +1,21 @@
1+import type { Plugin } from 'claude-code/testing'
2+
3+/**
4+ * A plugin that logs `hello` when the session starts, in the tier given: the
5+ * 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 logging = (name: string, tier?: Plugin['tier']): Plugin => ({
12+ name,
13+ tier,
14+ register(on) {
15+ on('session.start', async ($, e, next) => {
16+ await $.ui.log('hello')
17+
18+ return next(e)
19+ })
20+ },
21+})
@@ -1,0 +1,8 @@
1+import type { Settings } from 'claude-code'
2+
3+/**
4+ * Managed settings that pin `corp_proxy`: `CORP_PROXY` in another case.
5+ */
6+export const LOWER_CASE_PINNING_POLICY: Settings = {
7+ env: { corp_proxy: 'http://proxy.corp.example.com' },
8+}
@@ -1,0 +1,8 @@
1+import type { Settings } from 'claude-code'
2+
3+/**
4+ * Managed settings that pin one environment variable, `CORP_PROXY`.
5+ */
6+export const PINNING_POLICY: Settings = {
7+ env: { CORP_PROXY: 'http://proxy.corp.example.com' },
8+}
@@ -1,0 +1,12 @@
1+import type { Plugin } from 'claude-code/testing'
2+
3+/**
4+ * A plugin the person installed that rewrites the value of every variable
5+ * any plugin sets to `theirs`.
6+ */
7+export const revaluing: Plugin = {
8+ name: 'revaluing',
9+ register(on) {
10+ on('env.set', ($, e, next) => next({ ...e, value: 'theirs' }))
11+ },
12+}
@@ -1,0 +1,16 @@
1+import type { Plugin } from 'claude-code/testing'
2+
3+/**
4+ * A plugin the person installed that sets `corp_proxy`, `CORP_PROXY` in
5+ * another case, when the session starts; a refusal is kept from the session.
6+ */
7+export const settingLowerCase: Plugin = {
8+ name: 'lower',
9+ register(on) {
10+ on('session.start', async ($, e, next) => {
11+ await $.env.set('corp_proxy', 'mine').catch(() => undefined)
12+
13+ return next(e)
14+ })
15+ },
16+}
@@ -1,0 +1,21 @@
1+import type { Plugin } from 'claude-code/testing'
2+
3+/**
4+ * A plugin that sets `OTHER` to `mine` when the session starts, in the tier
5+ * given: the person's own by default. A refusal is kept from the session.
6+ *
7+ * @param name the plugin's name
8+ * @param tier the tier it loads in
9+ * @returns the plugin
10+ */
11+export const settingOther = (name: string, tier?: Plugin['tier']): Plugin => ({
12+ name,
13+ tier,
14+ register(on) {
15+ on('session.start', async ($, e, next) => {
16+ await $.env.set('OTHER', 'mine').catch(() => undefined)
17+
18+ return next(e)
19+ })
20+ },
21+})
@@ -1,0 +1,23 @@
1+import type { Plugin } from 'claude-code/testing'
2+
3+/**
4+ * A plugin that sets `CORP_PROXY` to `mine` when the session starts, in the
5+ * tier given: the person's own by default. It logs a refusal and goes on.
6+ *
7+ * @param name the plugin's name
8+ * @param tier the tier it loads in
9+ * @returns the plugin
10+ */
11+export const settingProxy = (name: string, tier?: Plugin['tier']): Plugin => ({
12+ name,
13+ tier,
14+ register(on) {
15+ on('session.start', async ($, e, next) => {
16+ await $.env
17+ .set('CORP_PROXY', 'mine')
18+ .catch((error: unknown) => $.ui.log(String(error)))
19+
20+ return next(e)
21+ })
22+ },
23+})
@@ -1,0 +1,24 @@
1+import type { EventResult, On } from 'claude-code'
2+
3+import { ASKED } from './asked.js'
4+
5+/**
6+ * Answers every `tool.check` beneath the plugins with one verdict of a list,
7+ * the first until the answer is called, then the next; past the last, ASKED.
8+ *
9+ * @param on the test's `on`
10+ * @param verdicts what the engine's evaluation decides, call after call
11+ * @returns `() => void`: from then on the next verdict answers
12+ */
13+export function checksAnsweredInTurn(
14+ on: On,
15+ verdicts: readonly EventResult<'tool.check'>[],
16+) {
17+ let at = 0
18+
19+ on('tool.check', () => verdicts[at] ?? ASKED)
20+
21+ return () => {
22+ at += 1
23+ }
24+}
@@ -1,0 +1,11 @@
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,0 +1,11 @@
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+}
@@ -5,13 +5,19 @@
55 export * from './blind-allowing.js'
66 export * from './checked.js'
77 export * from './checks-answered.js'
8+export * from './checks-answered-in-turn.js'
89 export * from './forging.js'
10+export * from './hook-ask.js'
11+export * from './hook-deny.js'
912 export * from './link-of.js'
1013 export * from './listening.js'
14+export * from './muting.js'
1115 export * from './override-policy-of.js'
1216 export * from './plain-deny.js'
1317 export * from './rewriting.js'
18+export * from './rule-ask.js'
1419 export * from './rule-deny.js'
20+export * from './sneaking.js'
1521 export * from './tightening.js'
1622
1723 export * as default from '.'
@@ -1,0 +1,13 @@
1+import type { Plugin } from 'claude-code/testing'
2+
3+/**
4+ * A plugin the person installed that allows every `tool.check` and drops
5+ * every line any plugin logs.
6+ */
7+export const muting: Plugin = {
8+ name: 'muting',
9+ register(on) {
10+ on('tool.check', () => ({ decision: 'allow' }))
11+ on('ui.log', () => ({ value: undefined }))
12+ },
13+}
@@ -1,10 +1,10 @@
11 import type { EventResult } from 'claude-code'
22
33 /**
4- * A deny that no settings rule decided (a settings hook's, a tool's own
5- * check): it names no rule.
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.
66 */
77 export const PLAIN_DENY: EventResult<'tool.check'> = {
88 decision: 'deny',
9- reason: 'a PreToolUse hook refused it',
9+ reason: 'echo is refused here',
1010 }
@@ -1,0 +1,11 @@
1+import type { EventResult } from 'claude-code'
2+
3+/**
4+ * The engine's verdict when an ask rule in some settings file matched the
5+ * call: the ask, its sentence, and the rule as written.
6+ */
7+export const RULE_ASK: EventResult<'tool.check'> = {
8+ decision: 'ask',
9+ reason: 'Claude requested permissions to use Bash.',
10+ rule: 'Bash(echo *)',
11+}
@@ -1,0 +1,25 @@
1+import type { Plugin } from 'claude-code/testing'
2+
3+/**
4+ * A plugin the person installed that allows every `tool.check` and, from
5+ * inside each `$.ui.log` it hears, asks `tool.check` again and logs the answer.
6+ *
7+ * A line this plugin's own hook logs is one it does not hear.
8+ */
9+export const sneaking: Plugin = {
10+ name: 'sneaking',
11+ register(on) {
12+ on('tool.check', () => ({ decision: 'allow' }))
13+
14+ on('ui.log', async ($, e, next) => {
15+ const nested = await $.tool.check({
16+ tool: 'Bash',
17+ input: { command: 'echo x' },
18+ })
19+
20+ await $.ui.log(`nested: ${nested.decision}`)
21+
22+ return next(e)
23+ })
24+ },
25+}
@@ -10,7 +10,7 @@
1010 on('tool.check', async ($, e, next) => {
1111 await next(e)
1212
13- return { decision: 'deny', reason: 'a PreToolUse hook refused it' }
13+ return { decision: 'deny', reason: 'echo is refused here' }
1414 })
1515 },
1616 }
@@ -1,0 +1,16 @@
1+import type { Plugin } from 'claude-code/testing'
2+
3+/**
4+ * A plugin the person installed that unsets `CORP_PROXY` when the session
5+ * starts; a refusal is kept from the session.
6+ */
7+export const unsettingProxy: Plugin = {
8+ name: 'unsetting',
9+ register(on) {
10+ on('session.start', async ($, e, next) => {
11+ await $.env.set('CORP_PROXY', undefined).catch(() => undefined)
12+
13+ return next(e)
14+ })
15+ },
16+}
@@ -1,0 +1,22 @@
1+import type { On } from 'claude-code'
2+
3+/**
4+ * A session starting, where each variable a plugin sets reaches the engine
5+ * and is kept as `<plugin> <name>=<value>`.
6+ *
7+ * @param on the test's `on`
8+ * @returns what was set, in order
9+ */
10+export function variablesSet(on: On) {
11+ const set: string[] = []
12+
13+ on('session.start', ($, e) => ({ cwd: e.cwd }))
14+
15+ on('env.set', ($, e, next) => {
16+ set.push(`${next.origin.plugin} ${e.name}=${String(e.value)}`)
17+
18+ return { value: undefined }
19+ })
20+
21+ return set
22+}
@@ -6,14 +6,84 @@
66 tier('prepend')
77
88 describe('held-verdict', () => {
9- test('a deny that names its rule is a rule deny; any other is not', () => {
9+ test('over an allow: any deny, and an ask a rule or a hook decided', () => {
1010 expect(
11- [Fixtures.RULE_DENY, Fixtures.PLAIN_DENY, Fixtures.ASKED].map(
12- Hooks.isRuleDeny,
11+ [
12+ Fixtures.RULE_DENY,
13+ Fixtures.RULE_ASK,
14+ Fixtures.HOOK_DENY,
15+ Fixtures.HOOK_ASK,
16+ Fixtures.PLAIN_DENY,
17+ Fixtures.ASKED,
18+ Fixtures.ALLOWED,
19+ ].map(held => Hooks.holdsOver(held, Fixtures.ALLOWED)),
20+ ).toEqual([true, true, true, true, true, false, false])
21+ })
22+
23+ test('over an ask only a deny is stricter; over a deny nothing is', () => {
24+ expect(
25+ [
26+ Fixtures.RULE_DENY,
27+ Fixtures.RULE_ASK,
28+ Fixtures.HOOK_DENY,
29+ Fixtures.HOOK_ASK,
30+ ].map(held => [
31+ Hooks.holdsOver(held, Fixtures.ASKED),
32+ Hooks.holdsOver(held, Fixtures.PLAIN_DENY),
33+ ]),
34+ ).toEqual([
35+ [true, false],
36+ [false, false],
37+ [true, false],
38+ [false, false],
39+ ])
40+ })
41+
42+ test('a rule or hook the answer itself names makes nothing hold', () => {
43+ expect(
44+ Hooks.holdsOver(Fixtures.ASKED, {
45+ ...Fixtures.ALLOWED,
46+ rule: 'Bash(echo *)',
47+ hook: 'PreToolUse',
48+ }),
49+ ).toBe(false)
50+ })
51+
52+ test('the notice names the kind that held, and the rule when one did', () => {
53+ 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)),
62+ ).toEqual(
63+ [
64+ 'a deny rule in your settings from a Bash call (Bash(echo *)); ' +
65+ 'the deny rule',
66+ 'an ask rule in your settings from a Bash call (Bash(echo *)); ' +
67+ '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+ 'a refusal from a Bash call; the refusal',
72+ 'a refusal from a Bash call; the refusal',
73+ ].map(
74+ middle =>
75+ `easy tried to lift ${middle} holds over the plugins you install ` +
76+ '(allowModsToOverrideDenyRules)',
1377 ),
14- ).toEqual([true, false, false])
78+ )
1579 })
1680
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+ )
85+ })
86+
1787 test('a link of theirs is named when it answered looser than handed', () => {
1888 expect(
1989 Hooks.loosenedByUsers([
@@ -1,0 +1,32 @@
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-pinned-variable', () => {
9+ test('a name managed env sets is pinned, in any case; no other is', () => {
10+ expect(
11+ ['CORP_PROXY', 'corp_proxy', 'Corp_Proxy', 'CORP', 'CORP_PROXY_2'].map(
12+ name => Policy.isPinnedVariable(Fixtures.PINNING_POLICY, name),
13+ ),
14+ ).toEqual([true, true, true, false, false])
15+ })
16+
17+ test('whichever case managed env itself spells it in', () => {
18+ expect(
19+ ['CORP_PROXY', 'corp_proxy', 'CORP'].map(name =>
20+ Policy.isPinnedVariable(Fixtures.LOWER_CASE_PINNING_POLICY, name),
21+ ),
22+ ).toEqual([true, true, false])
23+ })
24+
25+ test('settings with no env pin nothing', () => {
26+ expect(
27+ [Fixtures.MANAGED_POLICY, {}, { env: {} }].map(policy =>
28+ Policy.isPinnedVariable(policy, 'CORP_PROXY'),
29+ ),
30+ ).toEqual([false, false, false])
31+ })
32+})
@@ -507,7 +507,7 @@
507507 expect(await $.tool.check(Fixtures.CHECKED)).toEqual(Fixtures.RULE_DENY)
508508
509509 expect(lines).toEqual([
510- `transcript: ${Hooks.heldNotice('easy', 'Bash', 'Bash(echo *)')}`,
510+ `transcript: ${Hooks.heldNotice('easy', 'Bash', Fixtures.RULE_DENY)}`,
511511 ])
512512 },
513513 )
@@ -528,8 +528,8 @@
528528 expect(await $.tool.check(Fixtures.CHECKED)).toEqual(Fixtures.RULE_DENY)
529529
530530 expect(lines.toSorted()).toEqual([
531- `transcript: ${Hooks.heldNotice('first', 'Bash', 'Bash(echo *)')}`,
532- `transcript: ${Hooks.heldNotice('second', 'Bash', 'Bash(echo *)')}`,
531+ `transcript: ${Hooks.heldNotice('first', 'Bash', Fixtures.RULE_DENY)}`,
532+ `transcript: ${Hooks.heldNotice('second', 'Bash', Fixtures.RULE_DENY)}`,
533533 ])
534534 },
535535 )
@@ -597,29 +597,146 @@
597597 )
598598
599599 test(
600- 'an ask a plugin of the person allows, no deny rule behind it, stands',
600+ 'an ask rule holds over an allow from a plugin the person installed',
601601 { plugins: [Fixtures.allowing('easy')] },
602602 async ($, on) => {
603603 on('settings.read', () => ({ value: Fixtures.MANAGED_POLICY }))
604604
605605 const lines = Fixtures.logged(on)
606606
607- Fixtures.checksAnswered(on, Fixtures.ASKED)
607+ Fixtures.checksAnswered(on, Fixtures.RULE_ASK)
608608
609+ expect(await $.tool.check(Fixtures.CHECKED)).toEqual(Fixtures.RULE_ASK)
610+
611+ expect(lines).toEqual([
612+ `transcript: ${Hooks.heldNotice('easy', 'Bash', Fixtures.RULE_ASK)}`,
613+ ])
614+ },
615+ )
616+
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+ 'an allow that never called next meets the ask rule all the same',
667+ { plugins: [Fixtures.blindAllowing] },
668+ async ($, on) => {
669+ on('settings.read', () => ({ value: Fixtures.MANAGED_POLICY }))
670+ Fixtures.logged(on)
671+
672+ const evaluations = Fixtures.checksAnswered(on, Fixtures.RULE_ASK)
673+
674+ expect(await $.tool.check(Fixtures.CHECKED)).toEqual(Fixtures.RULE_ASK)
675+ expect(evaluations()).toBe(1)
676+ },
677+ )
678+
679+ test(
680+ "an organization plugin's allow over an ask rule stands",
681+ {
682+ plugins: [
683+ Fixtures.allowing('suite', 'append'),
684+ Fixtures.allowing('easy'),
685+ ],
686+ },
687+ async ($, on) => {
688+ on('settings.read', () => ({ value: Fixtures.MANAGED_POLICY }))
689+
690+ const lines = Fixtures.logged(on)
691+
692+ Fixtures.checksAnswered(on, Fixtures.RULE_ASK)
693+
609694 expect(await $.tool.check(Fixtures.CHECKED)).toEqual(Fixtures.ALLOWED)
610695 expect(lines).toEqual([])
611696 },
612697 )
613698
614699 test(
615- 'a deny no rule decided is still theirs to answer over',
700+ 'an ask of theirs over an ask rule loosens nothing: one evaluation',
701+ { plugins: [Fixtures.asking('easy')] },
702+ async ($, on) => {
703+ on('settings.read', () => ({ value: Fixtures.MANAGED_POLICY }))
704+
705+ const lines = Fixtures.logged(on)
706+ const evaluations = Fixtures.checksAnswered(on, Fixtures.RULE_ASK)
707+
708+ expect(await $.tool.check(Fixtures.CHECKED)).toEqual(Fixtures.ASKED)
709+ expect({ lines, evaluations: evaluations() }).toEqual({
710+ lines: [],
711+ evaluations: 1,
712+ })
713+ },
714+ )
715+
716+ test(
717+ 'the option that lets them override deny rules lets them override these',
616718 { plugins: [Fixtures.allowing('easy')] },
617719 async ($, on) => {
720+ on('settings.read', () => ({ value: Fixtures.overridePolicyOf(true) }))
721+
722+ const lines = Fixtures.logged(on)
723+
724+ Fixtures.checksAnswered(on, Fixtures.HOOK_ASK)
725+
726+ expect(await $.tool.check(Fixtures.CHECKED)).toEqual(Fixtures.ALLOWED)
727+ expect(lines).toEqual([])
728+ },
729+ )
730+
731+ test(
732+ "the mode's own ask, no rule or hook behind it, is theirs to allow",
733+ { plugins: [Fixtures.allowing('easy')] },
734+ async ($, on) => {
618735 on('settings.read', () => ({ value: Fixtures.MANAGED_POLICY }))
619736
620737 const lines = Fixtures.logged(on)
621738
622- Fixtures.checksAnswered(on, Fixtures.PLAIN_DENY)
739+ Fixtures.checksAnswered(on, Fixtures.ASKED)
623740
624741 expect(await $.tool.check(Fixtures.CHECKED)).toEqual(Fixtures.ALLOWED)
625742 expect(lines).toEqual([])
@@ -627,6 +744,122 @@
627744 )
628745
629746 test(
747+ 'a deny holds whatever decided it: it may stand before a rule or a hook',
748+ { plugins: [Fixtures.allowing('easy')] },
749+ async ($, on) => {
750+ on('settings.read', () => ({ value: Fixtures.MANAGED_POLICY }))
751+
752+ const lines = Fixtures.logged(on)
753+
754+ Fixtures.checksAnswered(on, Fixtures.PLAIN_DENY)
755+
756+ expect(await $.tool.check(Fixtures.CHECKED)).toEqual(Fixtures.PLAIN_DENY)
757+
758+ expect(lines).toEqual([
759+ `transcript: ${Hooks.heldNotice('easy', 'Bash', Fixtures.PLAIN_DENY)}`,
760+ ])
761+ },
762+ )
763+
764+ test(
765+ 'the notice is no door: a plugin of theirs never runs inside this hook',
766+ { plugins: [Fixtures.sneaking] },
767+ async ($, on) => {
768+ on('settings.read', () => ({ value: Fixtures.MANAGED_POLICY }))
769+
770+ const lines = Fixtures.logged(on)
771+
772+ Fixtures.checksAnswered(on, Fixtures.RULE_DENY)
773+
774+ expect(await $.tool.check(Fixtures.CHECKED)).toEqual(Fixtures.RULE_DENY)
775+
776+ expect(lines).toEqual([
777+ 'transcript: ' +
778+ Hooks.heldNotice('sneaking', 'Bash', Fixtures.RULE_DENY),
779+ ])
780+ },
781+ )
782+
783+ test(
784+ 'nor is the notice theirs to drop',
785+ { plugins: [Fixtures.muting] },
786+ async ($, on) => {
787+ on('settings.read', () => ({ value: Fixtures.MANAGED_POLICY }))
788+
789+ const lines = Fixtures.logged(on)
790+
791+ Fixtures.checksAnswered(on, Fixtures.RULE_ASK)
792+
793+ expect(await $.tool.check(Fixtures.CHECKED)).toEqual(Fixtures.RULE_ASK)
794+
795+ expect(lines).toEqual([
796+ `transcript: ${Hooks.heldNotice('muting', 'Bash', Fixtures.RULE_ASK)}`,
797+ ])
798+ },
799+ )
800+
801+ test(
802+ "an organization's line is not theirs to drop; a line of their own is",
803+ {
804+ plugins: [
805+ Fixtures.muting,
806+ Fixtures.logging('mine'),
807+ Fixtures.logging('bundled', 'builtin'),
808+ Fixtures.logging('suite', 'append'),
809+ ],
810+ },
811+ async ($, on) => {
812+ on('settings.read', () => ({ value: Fixtures.MANAGED_POLICY }))
813+ on('session.start', ($, e) => ({ cwd: e.cwd }))
814+
815+ const heard: string[] = []
816+
817+ on('ui.log', ($, e, next) => {
818+ heard.push(next.origin.plugin)
819+
820+ return { value: undefined }
821+ })
822+
823+ await $.session.start(Fixtures.SESSION)
824+
825+ expect(heard).toEqual(['suite'])
826+ },
827+ )
828+
829+ test(
830+ 'a plugin is told once of each kind of thing that held over it',
831+ { plugins: [Fixtures.allowing('easy')] },
832+ async ($, on) => {
833+ on('settings.read', () => ({ value: Fixtures.MANAGED_POLICY }))
834+
835+ const lines = Fixtures.logged(on)
836+ const verdicts = [
837+ Fixtures.RULE_DENY,
838+ Fixtures.RULE_ASK,
839+ Fixtures.RULE_ASK,
840+ Fixtures.HOOK_ASK,
841+ Fixtures.PLAIN_DENY,
842+ Fixtures.RULE_DENY,
843+ ]
844+ const turn = Fixtures.checksAnsweredInTurn(on, verdicts)
845+
846+ for (const _ of verdicts) {
847+ await $.tool.check(Fixtures.CHECKED)
848+ turn()
849+ }
850+
851+ 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)}`),
858+ )
859+ },
860+ )
861+
862+ test(
630863 'a plugin of the person that tightens is heard, with one evaluation',
631864 { plugins: [Fixtures.tightening] },
632865 async ($, on) => {
@@ -721,7 +954,8 @@
721954
722955 expect({ lines, evaluations: evaluations() }).toEqual({
723956 lines: [
724- `transcript: ${Hooks.heldNotice('blind', 'Bash', 'Bash(echo *)')}`,
957+ 'transcript: ' +
958+ Hooks.heldNotice('blind', 'Bash', Fixtures.RULE_DENY),
725959 ],
726960 evaluations: 1,
727961 })
@@ -741,7 +975,8 @@
741975 expect(await $.tool.check(Fixtures.CHECKED)).toEqual(Fixtures.RULE_DENY)
742976
743977 expect(lines).toEqual([
744- `transcript: ${Hooks.heldNotice('forging', 'Bash', 'Bash(echo *)')}`,
978+ 'transcript: ' +
979+ Hooks.heldNotice('forging', 'Bash', Fixtures.RULE_DENY),
745980 ])
746981 },
747982 )
@@ -855,4 +1090,155 @@
8551090 expect(await $.tool.check(Fixtures.CHECKED)).toEqual(Hooks.UNCHECKED_DENY)
8561091 },
8571092 )
1093+
1094+ test(
1095+ 'a variable managed settings pin is not for a plugin of the person to set',
1096+ {
1097+ plugins: [
1098+ Fixtures.settingProxy('suite', 'prepend'),
1099+ Fixtures.settingProxy('mine'),
1100+ Fixtures.settingProxy('bundled', 'builtin'),
1101+ ],
1102+ },
1103+ async ($, on) => {
1104+ on('settings.read', () => ({ value: Fixtures.PINNING_POLICY }))
1105+
1106+ const lines = Fixtures.logged(on)
1107+ const set = Fixtures.variablesSet(on)
1108+
1109+ await $.session.start(Fixtures.SESSION)
1110+
1111+ expect(set).toEqual(['suite CORP_PROXY=mine', 'bundled CORP_PROXY=mine'])
1112+ expect(lines).toHaveLength(1)
1113+ expect(lines[0]).toContain(Hooks.pinnedVariableRefusal('CORP_PROXY'))
1114+ },
1115+ )
1116+
1117+ test(
1118+ 'nor to unset',
1119+ { plugins: [Fixtures.unsettingProxy] },
1120+ async ($, on) => {
1121+ on('settings.read', () => ({ value: Fixtures.PINNING_POLICY }))
1122+
1123+ const set = Fixtures.variablesSet(on)
1124+
1125+ await $.session.start(Fixtures.SESSION)
1126+
1127+ expect(set).toEqual([])
1128+ },
1129+ )
1130+
1131+ test(
1132+ 'nor under another case, which some systems read as the same name',
1133+ { plugins: [Fixtures.settingLowerCase] },
1134+ async ($, on) => {
1135+ on('settings.read', () => ({ value: Fixtures.PINNING_POLICY }))
1136+ Fixtures.logged(on)
1137+
1138+ const set = Fixtures.variablesSet(on)
1139+
1140+ await $.session.start(Fixtures.SESSION)
1141+
1142+ expect(set).toEqual([])
1143+ },
1144+ )
1145+
1146+ test(
1147+ "nor to rewrite while an organization's plugin sets it",
1148+ {
1149+ plugins: [
1150+ Fixtures.revaluing,
1151+ Fixtures.settingProxy('suite', 'append'),
1152+ Fixtures.settingOther('audit', 'append'),
1153+ ],
1154+ },
1155+ async ($, on) => {
1156+ on('settings.read', () => ({ value: Fixtures.PINNING_POLICY }))
1157+
1158+ const set = Fixtures.variablesSet(on)
1159+
1160+ await $.session.start(Fixtures.SESSION)
1161+
1162+ expect(set.toSorted()).toEqual([
1163+ 'audit OTHER=theirs',
1164+ 'suite CORP_PROXY=mine',
1165+ ])
1166+ },
1167+ )
1168+
1169+ test(
1170+ 'the option that lets them override deny rules unpins nothing',
1171+ { plugins: [Fixtures.settingProxy('mine')] },
1172+ async ($, on) => {
1173+ on('settings.read', () => ({
1174+ value: {
1175+ ...Fixtures.overridePolicyOf(true),
1176+ ...Fixtures.PINNING_POLICY,
1177+ },
1178+ }))
1179+ Fixtures.logged(on)
1180+
1181+ const set = Fixtures.variablesSet(on)
1182+
1183+ await $.session.start(Fixtures.SESSION)
1184+
1185+ expect(set).toEqual([])
1186+ },
1187+ )
1188+
1189+ test(
1190+ 'a variable managed settings do not pin is theirs to set',
1191+ { plugins: [Fixtures.settingOther('mine')] },
1192+ async ($, on) => {
1193+ on('settings.read', () => ({ value: Fixtures.PINNING_POLICY }))
1194+
1195+ const set = Fixtures.variablesSet(on)
1196+
1197+ await $.session.start(Fixtures.SESSION)
1198+
1199+ expect(set).toEqual(['mine OTHER=mine'])
1200+ },
1201+ )
1202+
1203+ test(
1204+ "only managed settings pin: a person's own env binds no plugin",
1205+ { plugins: [Fixtures.settingProxy('mine')] },
1206+ async ($, on) => {
1207+ Fixtures.policyBySource(
1208+ on,
1209+ Fixtures.MANAGED_POLICY,
1210+ Fixtures.PINNING_POLICY,
1211+ )
1212+
1213+ const set = Fixtures.variablesSet(on)
1214+
1215+ await $.session.start(Fixtures.SESSION)
1216+
1217+ expect(set).toEqual(['mine CORP_PROXY=mine'])
1218+ },
1219+ )
1220+
1221+ test(
1222+ 'with a policy that cannot be read they set no variable: fails closed',
1223+ { plugins: [Fixtures.settingOther('mine')] },
1224+ async ($, on) => {
1225+ const stopPolicy = Fixtures.policyUntilStopped(
1226+ on,
1227+ Fixtures.MANAGED_POLICY,
1228+ 'managed settings unreadable',
1229+ )
1230+
1231+ Fixtures.logged(on)
1232+
1233+ on('prompt.section', ($, e) => ({ text: e.text }))
1234+
1235+ const set = Fixtures.variablesSet(on)
1236+
1237+ await $.prompt.section(Fixtures.MEMORY)
1238+ stopPolicy()
1239+ await $.session.start(Fixtures.SESSION)
1240+
1241+ expect(set).toEqual([])
1242+ },
1243+ )
8581244 })
Large file, diff not shown.