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
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.
| @@ -7,7 +7,7 @@ | ||
| 7 | 7 | |
| 8 | 8 | | Mod | What it does | Seated | |
| 9 | 9 | | --- | --- | --- | |
| 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 | | |
| 11 | 11 | | [`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 | |
| 12 | 12 | | [`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 | |
| 13 | 13 | | [`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 @@ | ||
| 1 | 1 | { |
| 2 | 2 | "name": "sec-default", |
| 3 | 3 | "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.", | |
| 5 | 5 | "author": { |
| 6 | 6 | "name": "Anthropic" |
| 7 | 7 | } |
| @@ -32,9 +32,11 @@ | ||
| 32 | 32 | | `tool.describe`, `command.describe`, `agent.offer`, `agent.spawn` | When the subject's pinned `e.provider.tier` is `prepend` or `append` (a policy-installed plugin, the managed folder, a policy MCP server), continue past the user tier; a subject provided by `user`, `builtin` or `core` passes. | |
| 33 | 33 | | `tool.register` | A caller in `prepend` or `append` continues past the user tier. A `user`-tier caller is refused by name while managed settings hold `allowedMcpServers` (set at all, empty included); otherwise it passes. | |
| 34 | 34 | | `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. | | |
| 36 | 38 | | `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`. | | |
| 38 | 40 | |
| 39 | 41 | ## Options an administrator sets |
| 40 | 42 | |
| @@ -87,25 +89,28 @@ | ||
| 87 | 89 | |
| 88 | 90 | `allowModsToOverrideDenyRules`: the plugins a person installs may answer |
| 89 | 91 | 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). | |
| 92 | 96 | |
| 93 | 97 | ## What it hooks |
| 94 | 98 | |
| 95 | 99 | `classic.*`, `prompt.section`, `prompt.context`, `prompt.compose`, `skill.prompt`, |
| 96 | 100 | `attribution.text`, `settings.read`, `tool.describe`, `command.describe`, |
| 97 | 101 | `agent.offer`, `agent.spawn`, `tool.register`, `tool.list`, `tool.check`, |
| 98 | -`plugin.register`. | |
| 102 | +`ui.log`, `env.set`, `plugin.register`. | |
| 99 | 103 | |
| 100 | 104 | Hooking `tool.check` has a cost: the engine raises that event only when some |
| 101 | 105 | loaded plugin hooks it, so where this plugin is seated every tool call now |
| 102 | 106 | 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. | |
| 104 | 109 | |
| 105 | 110 | ## What it calls on `$` |
| 106 | 111 | |
| 107 | 112 | `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 | |
| 109 | 114 | the `append` tier with `next.to`, which only a plugin in a managed tier may |
| 110 | 115 | do. |
| 111 | 116 | |
| @@ -113,8 +118,8 @@ | ||
| 113 | 118 | |
| 114 | 119 | On `tool.check` any hook may answer any verdict, so a plugin a person |
| 115 | 120 | 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: | |
| 118 | 123 | |
| 119 | 124 | - The hook first runs the chain as it is. If the answer is a deny, or no |
| 120 | 125 | link that may hold a person's plugin answered more permissively than the |
| @@ -131,34 +136,54 @@ | ||
| 131 | 136 | plugin, so neither the decision nor the rule it names can have been |
| 132 | 137 | rewritten or erased, and a plugin that answered without calling `next` |
| 133 | 138 | 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. | |
| 139 | 148 | - An organization's plugin (prepend or append) or a built-in that allows |
| 140 | 149 | over a deny rule takes part in both runs, so its answer stands (a prepended |
| 141 | 150 | 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, | |
| 144 | 153 | stands: that is what such a plugin is for. |
| 145 | 154 | - `tool.check` pins the question (`tool`, `input`, `tool_use_id`), so no hook |
| 146 | 155 | can have the rules evaluated on one command and another run; a rewrite |
| 147 | 156 | 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 | |
| 152 | 164 | 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). | |
| 155 | 179 | - If the hook itself fails, its `.catch` answers from the one run it can |
| 156 | 180 | 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. | |
| 159 | 183 | |
| 160 | 184 | 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: | |
| 162 | 187 | |
| 163 | 188 | ```json |
| 164 | 189 | { |
| @@ -173,7 +198,7 @@ | ||
| 173 | 198 | Only the managed source is read (`$.settings.read({ source: "policy" })`), |
| 174 | 199 | so the same key in a person's, a project's or a local settings file, or in |
| 175 | 200 | `--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. | |
| 177 | 202 | |
| 178 | 203 | ## Where it is seated |
| 179 | 204 |
| @@ -7,7 +7,7 @@ | ||
| 7 | 7 | * can read, the failed hook's last: that run's verdict, or a refusal. |
| 8 | 8 | * |
| 9 | 9 | * A deny stands, and so does a verdict no link that may hold a person's |
| 10 | - * plugin loosened. A loosened one, or none at all, met no deny rule. | |
| 10 | + * plugin loosened. A loosened one, or none at all, met no rule or hook. | |
| 11 | 11 | * |
| 12 | 12 | * @param last what that run settled on; undefined when it rejected |
| 13 | 13 | * @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 | + | |
| 1 | 5 | /** |
| 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. | |
| 4 | 8 | * |
| 5 | 9 | * It names the option an administrator sets to let such plugins override. |
| 6 | 10 | * |
| 7 | 11 | * @param plugin the plugin's name, or its batch's, as the trace names it |
| 8 | 12 | * @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 | |
| 10 | 14 | * @returns the line |
| 11 | 15 | */ |
| 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 @@ | ||
| 1 | 1 | export * from './caught-answer.js' |
| 2 | +export * from './held-kind.js' | |
| 2 | 3 | export * from './held-notice.js' |
| 3 | 4 | export * from './verdicts' |
| 4 | 5 |
| @@ -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' | |
| 2 | 2 | export * from './loosened-by-users.js' |
| 3 | 3 | export * from './ranking' |
| 4 | -export * from './types' | |
| 5 | 4 | export * from './unchecked-deny.js' |
| 6 | 5 | |
| 7 | 6 | export * as default from '.' |
| @@ -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 |
| @@ -1,3 +1,0 @@ | ||
| 1 | -export type * from './rule-deny.js' | |
| 2 | - | |
| 3 | -export * as default from '.' |
| @@ -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 @@ | ||
| 2 | 2 | |
| 3 | 3 | /** |
| 4 | 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. | |
| 5 | + * reached, and no check of the rules and hooks vouches for it: a deny. | |
| 6 | 6 | */ |
| 7 | 7 | export const UNCHECKED_DENY: EventResult<'tool.check'> = Object.freeze({ |
| 8 | 8 | decision: 'deny', |
| 9 | 9 | reason: |
| 10 | - 'the 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', | |
| 12 | 12 | }) |
| @@ -1,4 +1,4 @@ | ||
| 1 | 1 | { |
| 2 | - "description": "Security default: from the outermost seat, continues past the user tier on the organization's classic hooks, prompt content, settings and subjects, refuses a user-tier tool.register under an MCP allowlist, restores the organization's tools in tool.list, holds a 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", | |
| 3 | 3 | "modules": ["./register.ts"] |
| 4 | 4 | } |
| @@ -2,6 +2,7 @@ | ||
| 2 | 2 | export * from './held-verdict' |
| 3 | 3 | export * from './managed-mods-only-refusal' |
| 4 | 4 | export * from './past-users' |
| 5 | +export * from './pinned-variable-refusal' | |
| 5 | 6 | export * from './policy' |
| 6 | 7 | export * from './register.js' |
| 7 | 8 | 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 @@ | ||
| 6 | 6 | * Whether deny rules hold over the plugins a person installs: they do unless |
| 7 | 7 | * managed policy sets `allowModsToOverrideDenyRules` to the literal `true`. |
| 8 | 8 | * |
| 9 | - * 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. | |
| 11 | 12 | * |
| 12 | 13 | * @param policy the managed settings, as `$.settings.read` answers them |
| 13 | 14 | * @returns false only when the organization let a person's plugins override |
| @@ -3,6 +3,7 @@ | ||
| 3 | 3 | export * from './deny-rules-hold.js' |
| 4 | 4 | export * from './has-mcp-allowlist.js' |
| 5 | 5 | export * from './is-managed-mods-only.js' |
| 6 | +export * from './is-pinned-variable.js' | |
| 6 | 7 | export * from './managed-tools-restored' |
| 7 | 8 | export * from './own-option' |
| 8 | 9 | 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 @@ | ||
| 4 | 4 | import HeldVerdict from './held-verdict' |
| 5 | 5 | import { managedModsOnlyRefusal } from './managed-mods-only-refusal' |
| 6 | 6 | import { pastUsers } from './past-users' |
| 7 | +import { pinnedVariableRefusal } from './pinned-variable-refusal' | |
| 7 | 8 | import Policy from './policy' |
| 8 | 9 | import { TOOL_REGISTER_REFUSAL } from './tool-register-refusal' |
| 9 | 10 | |
| @@ -82,13 +83,15 @@ | ||
| 82 | 83 | |
| 83 | 84 | const held = await next.to(e, 'append') |
| 84 | 85 | |
| 85 | - if (!HeldVerdict.isRuleDeny(held)) { | |
| 86 | + if (!HeldVerdict.holdsOver(held, answer)) { | |
| 86 | 87 | return answer |
| 87 | 88 | } |
| 88 | 89 | |
| 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)) | |
| 92 | 95 | } |
| 93 | 96 | |
| 94 | 97 | return held |
| @@ -103,6 +106,40 @@ | ||
| 103 | 106 | return shouldVouch ? HeldVerdict.caughtAnswer(last, next.trace) : last |
| 104 | 107 | }) |
| 105 | 108 | |
| 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 | + | |
| 106 | 143 | on('plugin.register', { tier: 'user' }, async ($, e, next) => |
| 107 | 144 | Policy.isManagedModsOnly(await $.settings.read(Policy.SOURCE)) |
| 108 | 145 | ? { refuse: managedModsOnlyRefusal(e.name) } |
| @@ -10,6 +10,8 @@ | ||
| 10 | 10 | export * from './heading.js' |
| 11 | 11 | export * from './listing.js' |
| 12 | 12 | export * from './logged.js' |
| 13 | +export * from './logging.js' | |
| 14 | +export * from './lower-case-pinning-policy.js' | |
| 13 | 15 | export * from './managed-mods-only.js' |
| 14 | 16 | export * from './managed-policy.js' |
| 15 | 17 | export * from './marking.js' |
| @@ -18,6 +20,7 @@ | ||
| 18 | 20 | export * from './no-allowlist.js' |
| 19 | 21 | export * from './odd-providers.js' |
| 20 | 22 | export * from './org-providers.js' |
| 23 | +export * from './pinning-policy.js' | |
| 21 | 24 | export * from './policy-by-source.js' |
| 22 | 25 | export * from './policy-command.js' |
| 23 | 26 | export * from './policy-reads.js' |
| @@ -26,9 +29,13 @@ | ||
| 26 | 29 | export * from './registered-tool-of.js' |
| 27 | 30 | export * from './registering.js' |
| 28 | 31 | export * from './relabeling.js' |
| 32 | +export * from './revaluing.js' | |
| 29 | 33 | export * from './rewording.js' |
| 30 | 34 | export * from './server-policy.js' |
| 31 | 35 | export * from './session.js' |
| 36 | +export * from './setting-lower-case.js' | |
| 37 | +export * from './setting-other.js' | |
| 38 | +export * from './setting-proxy.js' | |
| 32 | 39 | export * from './signing.js' |
| 33 | 40 | export * from './stripping.js' |
| 34 | 41 | export * from './subjects-echoed.js' |
| @@ -37,6 +44,8 @@ | ||
| 37 | 44 | export * from './tools.js' |
| 38 | 45 | export * from './tools-command.js' |
| 39 | 46 | export * from './tools-registered.js' |
| 47 | +export * from './unsetting-proxy.js' | |
| 40 | 48 | export * from './user-reachable-providers.js' |
| 49 | +export * from './variables-set.js' | |
| 41 | 50 | |
| 42 | 51 | 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 @@ | ||
| 5 | 5 | export * from './blind-allowing.js' |
| 6 | 6 | export * from './checked.js' |
| 7 | 7 | export * from './checks-answered.js' |
| 8 | +export * from './checks-answered-in-turn.js' | |
| 8 | 9 | export * from './forging.js' |
| 10 | +export * from './hook-ask.js' | |
| 11 | +export * from './hook-deny.js' | |
| 9 | 12 | export * from './link-of.js' |
| 10 | 13 | export * from './listening.js' |
| 14 | +export * from './muting.js' | |
| 11 | 15 | export * from './override-policy-of.js' |
| 12 | 16 | export * from './plain-deny.js' |
| 13 | 17 | export * from './rewriting.js' |
| 18 | +export * from './rule-ask.js' | |
| 14 | 19 | export * from './rule-deny.js' |
| 20 | +export * from './sneaking.js' | |
| 15 | 21 | export * from './tightening.js' |
| 16 | 22 | |
| 17 | 23 | 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 @@ | ||
| 1 | 1 | import type { EventResult } from 'claude-code' |
| 2 | 2 | |
| 3 | 3 | /** |
| 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. | |
| 6 | 6 | */ |
| 7 | 7 | export const PLAIN_DENY: EventResult<'tool.check'> = { |
| 8 | 8 | decision: 'deny', |
| 9 | - reason: 'a PreToolUse hook refused it', | |
| 9 | + reason: 'echo is refused here', | |
| 10 | 10 | } |
| @@ -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 @@ | ||
| 10 | 10 | on('tool.check', async ($, e, next) => { |
| 11 | 11 | await next(e) |
| 12 | 12 | |
| 13 | - return { decision: 'deny', reason: 'a PreToolUse hook refused it' } | |
| 13 | + return { decision: 'deny', reason: 'echo is refused here' } | |
| 14 | 14 | }) |
| 15 | 15 | }, |
| 16 | 16 | } |
| @@ -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 @@ | ||
| 6 | 6 | tier('prepend') |
| 7 | 7 | |
| 8 | 8 | 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', () => { | |
| 10 | 10 | 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)', | |
| 13 | 77 | ), |
| 14 | - ).toEqual([true, false, false]) | |
| 78 | + ) | |
| 15 | 79 | }) |
| 16 | 80 | |
| 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 | + | |
| 17 | 87 | test('a link of theirs is named when it answered looser than handed', () => { |
| 18 | 88 | expect( |
| 19 | 89 | 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 @@ | ||
| 507 | 507 | expect(await $.tool.check(Fixtures.CHECKED)).toEqual(Fixtures.RULE_DENY) |
| 508 | 508 | |
| 509 | 509 | expect(lines).toEqual([ |
| 510 | - `transcript: ${Hooks.heldNotice('easy', 'Bash', 'Bash(echo *)')}`, | |
| 510 | + `transcript: ${Hooks.heldNotice('easy', 'Bash', Fixtures.RULE_DENY)}`, | |
| 511 | 511 | ]) |
| 512 | 512 | }, |
| 513 | 513 | ) |
| @@ -528,8 +528,8 @@ | ||
| 528 | 528 | expect(await $.tool.check(Fixtures.CHECKED)).toEqual(Fixtures.RULE_DENY) |
| 529 | 529 | |
| 530 | 530 | 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)}`, | |
| 533 | 533 | ]) |
| 534 | 534 | }, |
| 535 | 535 | ) |
| @@ -597,29 +597,146 @@ | ||
| 597 | 597 | ) |
| 598 | 598 | |
| 599 | 599 | 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', | |
| 601 | 601 | { plugins: [Fixtures.allowing('easy')] }, |
| 602 | 602 | async ($, on) => { |
| 603 | 603 | on('settings.read', () => ({ value: Fixtures.MANAGED_POLICY })) |
| 604 | 604 | |
| 605 | 605 | const lines = Fixtures.logged(on) |
| 606 | 606 | |
| 607 | - Fixtures.checksAnswered(on, Fixtures.ASKED) | |
| 607 | + Fixtures.checksAnswered(on, Fixtures.RULE_ASK) | |
| 608 | 608 | |
| 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 | + | |
| 609 | 694 | expect(await $.tool.check(Fixtures.CHECKED)).toEqual(Fixtures.ALLOWED) |
| 610 | 695 | expect(lines).toEqual([]) |
| 611 | 696 | }, |
| 612 | 697 | ) |
| 613 | 698 | |
| 614 | 699 | 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', | |
| 616 | 718 | { plugins: [Fixtures.allowing('easy')] }, |
| 617 | 719 | 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) => { | |
| 618 | 735 | on('settings.read', () => ({ value: Fixtures.MANAGED_POLICY })) |
| 619 | 736 | |
| 620 | 737 | const lines = Fixtures.logged(on) |
| 621 | 738 | |
| 622 | - Fixtures.checksAnswered(on, Fixtures.PLAIN_DENY) | |
| 739 | + Fixtures.checksAnswered(on, Fixtures.ASKED) | |
| 623 | 740 | |
| 624 | 741 | expect(await $.tool.check(Fixtures.CHECKED)).toEqual(Fixtures.ALLOWED) |
| 625 | 742 | expect(lines).toEqual([]) |
| @@ -627,6 +744,122 @@ | ||
| 627 | 744 | ) |
| 628 | 745 | |
| 629 | 746 | 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( | |
| 630 | 863 | 'a plugin of the person that tightens is heard, with one evaluation', |
| 631 | 864 | { plugins: [Fixtures.tightening] }, |
| 632 | 865 | async ($, on) => { |
| @@ -721,7 +954,8 @@ | ||
| 721 | 954 | |
| 722 | 955 | expect({ lines, evaluations: evaluations() }).toEqual({ |
| 723 | 956 | lines: [ |
| 724 | - `transcript: ${Hooks.heldNotice('blind', 'Bash', 'Bash(echo *)')}`, | |
| 957 | + 'transcript: ' + | |
| 958 | + Hooks.heldNotice('blind', 'Bash', Fixtures.RULE_DENY), | |
| 725 | 959 | ], |
| 726 | 960 | evaluations: 1, |
| 727 | 961 | }) |
| @@ -741,7 +975,8 @@ | ||
| 741 | 975 | expect(await $.tool.check(Fixtures.CHECKED)).toEqual(Fixtures.RULE_DENY) |
| 742 | 976 | |
| 743 | 977 | expect(lines).toEqual([ |
| 744 | - `transcript: ${Hooks.heldNotice('forging', 'Bash', 'Bash(echo *)')}`, | |
| 978 | + 'transcript: ' + | |
| 979 | + Hooks.heldNotice('forging', 'Bash', Fixtures.RULE_DENY), | |
| 745 | 980 | ]) |
| 746 | 981 | }, |
| 747 | 982 | ) |
| @@ -855,4 +1090,155 @@ | ||
| 855 | 1090 | expect(await $.tool.check(Fixtures.CHECKED)).toEqual(Hooks.UNCHECKED_DENY) |
| 856 | 1091 | }, |
| 857 | 1092 | ) |
| 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 | + ) | |
| 858 | 1244 | }) |
Large file, diff not shown.