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
| @@ -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 and tool policy out of reach of the plugins a person installs; adds no policy of its own. | Outermost, on a machine with managed settings or for a Team or Enterprise organization, unless managed `prependPlugins` says otherwise | | |
| 10 | +| [`sec-default`](sec-default) | Keeps an organization's classic hooks, prompt content, managed settings, tool policy and deny rules out of reach of the plugins a person installs; adds no policy of its own. | Outermost, on a machine with managed settings or for a Team or Enterprise organization, unless managed `prependPlugins` says otherwise | | |
| 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 | |
| @@ -4,10 +4,11 @@ | ||
| 4 | 4 | say on every event, in chain order, and the plugins a person installs sit |
| 5 | 5 | in the user tier, beneath the organization's prepend tier and above its |
| 6 | 6 | append tier. Some of what an organization sets today (its classic hooks, |
| 7 | -its managed CLAUDE.md and rules, its settings, its MCP allowlist) was never | |
| 8 | -within a person's reach before function hooks; seated outermost, this | |
| 9 | -plugin keeps exactly those out of the user tier's reach and adds no policy | |
| 10 | -of its own. Everything else passes through untouched. | |
| 7 | +its managed CLAUDE.md and rules, its settings, its MCP allowlist, the deny | |
| 8 | +rules in force on its machines) was never within a person's reach before | |
| 9 | +function hooks; seated outermost, this plugin keeps exactly those out of the | |
| 10 | +user tier's reach and adds no policy of its own. Everything else passes | |
| 11 | +through untouched. | |
| 11 | 12 | |
| 12 | 13 | It has three moves and nothing else: continue past the user tier |
| 13 | 14 | (`next.to(e, "append")`), refuse a user-tier caller by name (`{ deny }` |
| @@ -29,19 +30,81 @@ | ||
| 29 | 30 | | `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. | |
| 30 | 31 | | `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. | |
| 31 | 32 | | `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. | |
| 32 | -| everything else | Passes: `prompt.submit`, `turn.*`, `tool.call`, `tool.check`, `command.run`, `command.register`, `session.*`, `ui.*`, `fs.*`, `http.fetch`, `process.run`, `store.*`, `clock.*`, `model.*`, `mcp.call`, `audio.*`, `agent.list`, `engine.create`. | | |
| 33 | +| `tool.check` | A deny that a settings rule decided holds over the user tier: when a person's plugin answered `allow` or `ask`, 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. | | |
| 34 | +| 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`. | | |
| 33 | 35 | |
| 34 | 36 | ## What it hooks |
| 35 | 37 | |
| 36 | 38 | `classic.*`, `prompt.section`, `prompt.context`, `skill.prompt`, |
| 37 | 39 | `attribution.text`, `settings.read`, `tool.describe`, `command.describe`, |
| 38 | -`agent.offer`, `agent.spawn`, `tool.register`, `tool.list`. | |
| 40 | +`agent.offer`, `agent.spawn`, `tool.register`, `tool.list`, `tool.check`. | |
| 39 | 41 | |
| 42 | +Hooking `tool.check` has a cost: the engine raises that event only when some | |
| 43 | +loaded plugin hooks it, so where this plugin is seated every tool call now | |
| 44 | +runs the `tool.check` chain, where before only a session with such a plugin | |
| 45 | +did. | |
| 46 | + | |
| 40 | 47 | ## What it calls on `$` |
| 41 | 48 | |
| 42 | -`settings.read`. It continues to the `append` tier with `next.to`, which | |
| 43 | -only a plugin in a managed tier may do. | |
| 49 | +`settings.read`, and `ui.log` for the one line a person reads when a deny | |
| 50 | +rule held over a plugin of theirs. It continues to the `append` tier with | |
| 51 | +`next.to`, which only a plugin in a managed tier may do. | |
| 44 | 52 | |
| 53 | +## Deny rules hold | |
| 54 | + | |
| 55 | +On `tool.check` any hook may answer any verdict, so a plugin a person | |
| 56 | +installs to stop the permission prompts (`() => ({ decision: "allow" })`) | |
| 57 | +would also lift a deny rule, a managed one included. Where this plugin is | |
| 58 | +seated it does not: | |
| 59 | + | |
| 60 | +- The hook first runs the chain as it is. If the answer is a deny, or no | |
| 61 | + user-tier link answered `allow` or `ask` (read off `next.trace`, whose | |
| 62 | + tiers the engine pins), the answer passes: nothing of the person's loosened | |
| 63 | + anything. | |
| 64 | +- Otherwise it runs the dispatch once more with the user tier left out | |
| 65 | + (`next.to(e, "append")`). That verdict never passed through a person's | |
| 66 | + plugin, so neither the decision nor the rule it names can have been | |
| 67 | + rewritten or erased, and a plugin that answered without calling `next` | |
| 68 | + changes nothing: the rules are evaluated in this run. The two runs differ | |
| 69 | + by the user tier alone, so a deny here that names its rule is a deny rule | |
| 70 | + the user tier loosened, and it is returned in place of the chain's answer. | |
| 71 | +- Any deny rule counts, whatever settings file it came from: a verdict | |
| 72 | + carries the rule as written, never where it was read from. A deny that | |
| 73 | + names no rule (a settings hook's, a tool's own check) is not held. | |
| 74 | +- An organization's plugin (prepend or append) or a built-in that allows | |
| 75 | + over a deny rule takes part in both runs, so its answer stands. An ask | |
| 76 | + that a person's plugin turns into an allow, with no deny rule behind it, | |
| 77 | + stands: that is what such a plugin is for. | |
| 78 | +- `tool.check` pins the question (`tool`, `input`, `tool_use_id`), so no hook | |
| 79 | + can have the rules evaluated on one command and another run; a rewrite | |
| 80 | + belongs to `tool.call`, which runs before any of this. | |
| 81 | +- The person is told once for each plugin in a session, in the transcript | |
| 82 | + and the debug log: `<plugin> tried to allow a <tool> call your settings | |
| 83 | + deny (<rule>); the deny rule holds over the plugins you install | |
| 84 | + (allowModsToOverrideDenyRules)`. | |
| 85 | +- If the hook itself fails, its `.catch` answers from the one run it can | |
| 86 | + read: a deny stands; a verdict no plugin of the person's loosened stands; | |
| 87 | + one they loosened, or a run that rejected, is refused, since the deny | |
| 88 | + rules were never consulted. | |
| 89 | + | |
| 90 | +An organization that wants the plugins its people install to override deny | |
| 91 | +rules says so in managed settings, under this plugin's own options: | |
| 92 | + | |
| 93 | +```json | |
| 94 | +{ | |
| 95 | + "pluginConfigs": { | |
| 96 | + "cc-plugin-sec-default@builtin": { | |
| 97 | + "options": { "allowModsToOverrideDenyRules": true } | |
| 98 | + } | |
| 99 | + } | |
| 100 | +} | |
| 101 | +``` | |
| 102 | + | |
| 103 | +Only the managed source is read (`$.settings.read({ source: "policy" })`), | |
| 104 | +so the same key in a person's, a project's or a local settings file, or in | |
| 105 | +`--settings`, is never consulted; only the literal `true` counts, and a | |
| 106 | +policy that cannot be read leaves deny rules holding. | |
| 107 | + | |
| 45 | 108 | ## Where it is seated |
| 46 | 109 | |
| 47 | 110 | The CLI seats it first in the prepend tier wherever hooks modules load on a |
| @@ -1,0 +1,25 @@ | ||
| 1 | +import type { EventResult, TraceEntry } from 'claude-code' | |
| 2 | + | |
| 3 | +import Verdicts from './verdicts' | |
| 4 | + | |
| 5 | +/** | |
| 6 | + * What the `tool.check` hook's failure handler answers from the one run it | |
| 7 | + * can read, the failed hook's last: that run's verdict, or a refusal. | |
| 8 | + * | |
| 9 | + * A verdict no plugin a person installed loosened stands (a deny, the run | |
| 10 | + * past the user tier). A loosened one, or none at all, met no deny rule. | |
| 11 | + * | |
| 12 | + * @param last what that run settled on; undefined when it rejected | |
| 13 | + * @param trace that run's `next.trace` | |
| 14 | + * @returns the verdict the handler returns | |
| 15 | + */ | |
| 16 | +export function caughtAnswer( | |
| 17 | + last: EventResult<'tool.check'> | undefined, | |
| 18 | + trace: readonly TraceEntry<'tool.check'>[], | |
| 19 | +) { | |
| 20 | + const isVouched = | |
| 21 | + last !== undefined && | |
| 22 | + (last.decision === 'deny' || Verdicts.loosenedByUsers(trace).length === 0) | |
| 23 | + | |
| 24 | + return isVouched ? last : Verdicts.UNCHECKED_DENY | |
| 25 | +} |
| @@ -1,0 +1,15 @@ | ||
| 1 | +/** | |
| 2 | + * What a person reads, once for each plugin in a session, when a plugin they | |
| 3 | + * installed allowed a call that a deny rule in their settings refuses. | |
| 4 | + * | |
| 5 | + * It names the option an administrator sets to let such plugins override. | |
| 6 | + * | |
| 7 | + * @param plugin the plugin's name, as the chain's trace names its link | |
| 8 | + * @param tool the tool the call named | |
| 9 | + * @param rule the deny rule that decided, as written | |
| 10 | + * @returns the line | |
| 11 | + */ | |
| 12 | +export const heldNotice = (plugin: string, tool: string, rule: string) => | |
| 13 | + `${plugin} tried to allow a ${tool} call your settings deny (${rule}); ` + | |
| 14 | + 'the deny rule holds over the plugins you install ' + | |
| 15 | + '(allowModsToOverrideDenyRules)' |
| @@ -1,0 +1,5 @@ | ||
| 1 | +export * from './caught-answer.js' | |
| 2 | +export * from './held-notice.js' | |
| 3 | +export * from './verdicts' | |
| 4 | + | |
| 5 | +export * as default from '.' |
| @@ -1,0 +1,6 @@ | ||
| 1 | +export * from './is-rule-deny.js' | |
| 2 | +export * from './loosened-by-users.js' | |
| 3 | +export * from './types' | |
| 4 | +export * from './unchecked-deny.js' | |
| 5 | + | |
| 6 | +export * as default from '.' |
| @@ -1,0 +1,18 @@ | ||
| 1 | +import type { EventResult } from 'claude-code' | |
| 2 | + | |
| 3 | +import type { RuleDeny } from './types' | |
| 4 | + | |
| 5 | +/** | |
| 6 | + * Whether a `tool.check` verdict is a deny that a settings rule decided: the | |
| 7 | + * one verdict the plugins a person installs may not loosen. | |
| 8 | + * | |
| 9 | + * Any deny rule counts, whatever settings file it came from: a verdict | |
| 10 | + * carries the rule as written and never where it was read from. | |
| 11 | + * | |
| 12 | + * @param verdict what a run of the chain settled on | |
| 13 | + * @returns true for a deny that names the rule behind it | |
| 14 | + */ | |
| 15 | +export const isRuleDeny = ( | |
| 16 | + verdict: EventResult<'tool.check'>, | |
| 17 | +): verdict is RuleDeny => | |
| 18 | + verdict.decision === 'deny' && verdict.rule !== undefined |
| @@ -1,0 +1,23 @@ | ||
| 1 | +import type { TraceEntry } from 'claude-code' | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * The plugins a person installed that answered `allow` or `ask` in one run | |
| 5 | + * of `tool.check`, by name, nearest the caller first. | |
| 6 | + * | |
| 7 | + * Read off `next.trace`: each link's `tier` is pinned by the engine, and a | |
| 8 | + * link that was skipped or rejected settled on nothing. | |
| 9 | + * | |
| 10 | + * @param trace what settled beneath the hook on its latest `next` call | |
| 11 | + * @returns the names; none when no user-tier link answered looser than deny | |
| 12 | + */ | |
| 13 | +export const loosenedByUsers = ( | |
| 14 | + trace: readonly TraceEntry<'tool.check'>[], | |
| 15 | +): readonly string[] => | |
| 16 | + trace | |
| 17 | + .filter( | |
| 18 | + link => | |
| 19 | + link.tier === 'user' && | |
| 20 | + link.returned !== undefined && | |
| 21 | + link.returned.decision !== 'deny', | |
| 22 | + ) | |
| 23 | + .map(link => link.plugin) |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export type * from './rule-deny.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,9 @@ | ||
| 1 | +import type { EventResult } from 'claude-code' | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * A `tool.check` deny that names the settings rule behind it. | |
| 5 | + */ | |
| 6 | +export type RuleDeny = EventResult<'tool.check'> & { | |
| 7 | + readonly decision: 'deny' | |
| 8 | + readonly rule: string | |
| 9 | +} |
| @@ -1,0 +1,12 @@ | ||
| 1 | +import type { EventResult } from 'claude-code' | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * What the failure handler answers when a verdict was loosened, or never | |
| 5 | + * reached, and no deny rule check vouches for it: absent counts as deny. | |
| 6 | + */ | |
| 7 | +export const UNCHECKED_DENY: EventResult<'tool.check'> = Object.freeze({ | |
| 8 | + decision: 'deny', | |
| 9 | + reason: | |
| 10 | + 'the deny rules in your settings could not be checked for this call, ' + | |
| 11 | + 'so it is refused', | |
| 12 | +}) |
| @@ -1,4 +1,4 @@ | ||
| 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, and restores the organization's tools in tool.list", | |
| 2 | + "description": "Security default: from the outermost seat, continues past the user tier on the organization's classic hooks, prompt content, settings and subjects, refuses a user-tier tool.register under an MCP allowlist, restores the organization's tools in tool.list, and holds a settings deny rule over a user-tier allow or ask on tool.check", | |
| 3 | 3 | "modules": ["./register.ts"] |
| 4 | 4 | } |
| @@ -1,3 +1,4 @@ | ||
| 1 | +export * from './held-verdict' | |
| 1 | 2 | export * from './past-users' |
| 2 | 3 | export * from './policy' |
| 3 | 4 | export * from './register.js' |
| @@ -1,0 +1,16 @@ | ||
| 1 | +import type { Settings } from 'claude-code' | |
| 2 | + | |
| 3 | +import { ownOption } from './own-option' | |
| 4 | + | |
| 5 | +/** | |
| 6 | + * Whether deny rules hold over the plugins a person installs: they do unless | |
| 7 | + * managed policy sets `allowModsToOverrideDenyRules` to the literal `true`. | |
| 8 | + * | |
| 9 | + * A value mistyped (`"true"`, `1`) loosens nothing. Only the policy source | |
| 10 | + * is handed in, so a person's settings never reach this. | |
| 11 | + * | |
| 12 | + * @param policy the managed settings, as `$.settings.read` answers them | |
| 13 | + * @returns false only when the organization let a person's plugins override | |
| 14 | + */ | |
| 15 | +export const denyRulesHold = (policy: Settings) => | |
| 16 | + ownOption(policy, 'allowModsToOverrideDenyRules') !== true |
| @@ -1,7 +1,9 @@ | ||
| 1 | 1 | export * from './create-policy-memo' |
| 2 | 2 | export * from './decided-by-policy.js' |
| 3 | +export * from './deny-rules-hold.js' | |
| 3 | 4 | export * from './has-mcp-allowlist.js' |
| 4 | 5 | export * from './managed-tools-restored' |
| 6 | +export * from './own-option' | |
| 5 | 7 | export * from './policy-memo-ms.js' |
| 6 | 8 | export * from './source.js' |
| 7 | 9 |
| @@ -1,0 +1,5 @@ | ||
| 1 | +export * from './member-of' | |
| 2 | +export * from './own-option.js' | |
| 3 | +export * from './own-options-path' | |
| 4 | + | |
| 5 | +export * as default from '.' |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export * from './member-of.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,13 @@ | ||
| 1 | +/** | |
| 2 | + * One step into settings read as plain data: the value's own member of that | |
| 3 | + * name, or undefined when the value is no object or holds none. | |
| 4 | + * | |
| 5 | + * @param value what was read so far | |
| 6 | + * @param key the member's name | |
| 7 | + * @returns the member, or undefined | |
| 8 | + */ | |
| 9 | +export function memberOf(value: unknown, key: string): unknown { | |
| 10 | + const isObject = typeof value === 'object' && value !== null | |
| 11 | + | |
| 12 | + return isObject ? new Map(Object.entries(value)).get(key) : undefined | |
| 13 | +} |
| @@ -1,0 +1,17 @@ | ||
| 1 | +import type { Settings } from 'claude-code' | |
| 2 | + | |
| 3 | +import { memberOf } from './member-of' | |
| 4 | +import { OWN_OPTIONS_PATH } from './own-options-path' | |
| 5 | + | |
| 6 | +/** | |
| 7 | + * One option an administrator set for this plugin in managed settings: its | |
| 8 | + * value under the plugin's own `pluginConfigs` entry, as written. | |
| 9 | + * | |
| 10 | + * Hand it the policy source alone, so a person's settings never reach it. | |
| 11 | + * | |
| 12 | + * @param policy the managed settings, as `$.settings.read` answers them | |
| 13 | + * @param name the option's name | |
| 14 | + * @returns the value, or undefined when the option is not set | |
| 15 | + */ | |
| 16 | +export const ownOption = (policy: Settings, name: string): unknown => | |
| 17 | + [...OWN_OPTIONS_PATH, name].reduce<unknown>(memberOf, policy) |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export * from './own-options-path.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,9 @@ | ||
| 1 | +/** | |
| 2 | + * Where managed settings hold this plugin's options: its own `pluginConfigs` | |
| 3 | + * entry, keyed by the id the CLI builds it in under. | |
| 4 | + */ | |
| 5 | +export const OWN_OPTIONS_PATH = [ | |
| 6 | + 'pluginConfigs', | |
| 7 | + 'cc-plugin-sec-default@builtin', | |
| 8 | + 'options', | |
| 9 | +] as const |
| @@ -1,5 +1,6 @@ | ||
| 1 | 1 | import type { On } from 'claude-code' |
| 2 | 2 | |
| 3 | +import HeldVerdict from './held-verdict' | |
| 3 | 4 | import { pastUsers } from './past-users' |
| 4 | 5 | import Policy from './policy' |
| 5 | 6 | import { TOOL_REGISTER_REFUSAL } from './tool-register-refusal' |
| @@ -9,13 +10,14 @@ | ||
| 9 | 10 | * organization has today out of reach of the plugins a person installs. |
| 10 | 11 | * |
| 11 | 12 | * Three moves: continue past the user tier (`next.to(e, "append")`), refuse |
| 12 | - * a user-tier caller by name, or pass. Provenance is the event's pinned | |
| 13 | - * `provider`; policy is `$.settings.read`, memoized per burst; fail closed. | |
| 13 | + * a user-tier caller or module by name, or pass. Provenance is the event's | |
| 14 | + * pinned `provider` or `tier`; policy is `$.settings.read`; fail closed. | |
| 14 | 15 | * |
| 15 | 16 | * @param on the engine's registrar |
| 16 | 17 | */ |
| 17 | 18 | export function register(on: On) { |
| 18 | 19 | const readPolicy = Policy.createPolicyMemo(Policy.POLICY_MEMO_MS) |
| 20 | + const told = new Set<string>() | |
| 19 | 21 | |
| 20 | 22 | on('classic.*', ($, e, next) => next.to(e, 'append')) |
| 21 | 23 | |
| @@ -58,4 +60,43 @@ | ||
| 58 | 60 | await next(e), |
| 59 | 61 | ), |
| 60 | 62 | ) |
| 63 | + | |
| 64 | + on('tool.check', async ($, e, next) => { | |
| 65 | + const answer = await next(e) | |
| 66 | + const mods = HeldVerdict.loosenedByUsers(next.trace) | |
| 67 | + | |
| 68 | + const shouldRecheck = | |
| 69 | + answer.decision !== 'deny' && | |
| 70 | + mods.length > 0 && | |
| 71 | + (await Policy.decidedByPolicy( | |
| 72 | + readPolicy(() => $.settings.read(Policy.SOURCE)), | |
| 73 | + Policy.denyRulesHold, | |
| 74 | + )) | |
| 75 | + | |
| 76 | + if (!shouldRecheck) { | |
| 77 | + return answer | |
| 78 | + } | |
| 79 | + | |
| 80 | + const held = await next.to(e, 'append') | |
| 81 | + | |
| 82 | + if (!HeldVerdict.isRuleDeny(held)) { | |
| 83 | + return answer | |
| 84 | + } | |
| 85 | + | |
| 86 | + for (const mod of mods.filter(name => !told.has(name))) { | |
| 87 | + told.add(mod) | |
| 88 | + $.ui.log(HeldVerdict.heldNotice(mod, e.tool, held.rule)) | |
| 89 | + } | |
| 90 | + | |
| 91 | + return held | |
| 92 | + }).catch(async ($, e, next) => { | |
| 93 | + const last = await next(e).catch(() => undefined) | |
| 94 | + | |
| 95 | + const shouldVouch = await Policy.decidedByPolicy( | |
| 96 | + readPolicy(() => $.settings.read(Policy.SOURCE)), | |
| 97 | + Policy.denyRulesHold, | |
| 98 | + ) | |
| 99 | + | |
| 100 | + return shouldVouch ? HeldVerdict.caughtAnswer(last, next.trace) : last | |
| 101 | + }) | |
| 61 | 102 | } |
| @@ -6,12 +6,14 @@ | ||
| 6 | 6 | export * from './dropping.js' |
| 7 | 7 | export * from './fullscreen.js' |
| 8 | 8 | export * from './listing.js' |
| 9 | +export * from './logged.js' | |
| 9 | 10 | export * from './managed-policy.js' |
| 10 | 11 | export * from './marking.js' |
| 11 | 12 | export * from './memory.js' |
| 12 | 13 | export * from './no-allowlist.js' |
| 13 | 14 | export * from './odd-providers.js' |
| 14 | 15 | export * from './org-providers.js' |
| 16 | +export * from './policy-by-source.js' | |
| 15 | 17 | export * from './policy-command.js' |
| 16 | 18 | export * from './policy-reads.js' |
| 17 | 19 | export * from './reading.js' |
| @@ -23,6 +25,7 @@ | ||
| 23 | 25 | export * from './signing.js' |
| 24 | 26 | export * from './stripping.js' |
| 25 | 27 | export * from './subjects-echoed.js' |
| 28 | +export * from './tool-check' | |
| 26 | 29 | export * from './tool-described.js' |
| 27 | 30 | export * from './tools.js' |
| 28 | 31 | export * from './tools-command.js' |
| @@ -1,0 +1,20 @@ | ||
| 1 | +import type { On } from 'claude-code' | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * Answers every `$.ui.log` beneath the plugins and keeps each line as | |
| 5 | + * `<to>: <text>`, in order. | |
| 6 | + * | |
| 7 | + * @param on the test's `on` | |
| 8 | + * @returns the lines logged so far | |
| 9 | + */ | |
| 10 | +export function logged(on: On) { | |
| 11 | + const lines: string[] = [] | |
| 12 | + | |
| 13 | + on('ui.log', ($, e) => { | |
| 14 | + lines.push(`${e.to}: ${e.text}`) | |
| 15 | + | |
| 16 | + return { value: undefined } | |
| 17 | + }) | |
| 18 | + | |
| 19 | + return lines | |
| 20 | +} |
| @@ -1,0 +1,15 @@ | ||
| 1 | +import type { On, Settings } from 'claude-code' | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * Answers a read of the policy source with one settings object and every | |
| 5 | + * other read, a named source or the merge, with another. | |
| 6 | + * | |
| 7 | + * @param on the test's `on` | |
| 8 | + * @param policy what `{ source: "policy" }` reads | |
| 9 | + * @param others what any other read answers | |
| 10 | + * @returns the registration | |
| 11 | + */ | |
| 12 | +export const policyBySource = (on: On, policy: Settings, others: Settings) => | |
| 13 | + on('settings.read', ($, e) => ({ | |
| 14 | + value: e.source === 'policy' ? policy : others, | |
| 15 | + })) |
| @@ -1,0 +1,6 @@ | ||
| 1 | +import type { EventResult } from 'claude-code' | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * What an auto-approve plugin answers every `tool.check`: allow. | |
| 5 | + */ | |
| 6 | +export const ALLOWED: EventResult<'tool.check'> = { decision: 'allow' } |
| @@ -1,0 +1,27 @@ | ||
| 1 | +import type { EventResult } from 'claude-code' | |
| 2 | +import type { Plugin } from 'claude-code/testing' | |
| 3 | + | |
| 4 | +/** | |
| 5 | + * A plugin that hears the verdict beneath it on `tool.check`, then answers | |
| 6 | + * its own whatever it heard: the person's own by default. | |
| 7 | + * | |
| 8 | + * @param name the plugin's name | |
| 9 | + * @param verdict what it answers | |
| 10 | + * @param tier the tier it loads in | |
| 11 | + * @returns the plugin | |
| 12 | + */ | |
| 13 | +export const answering = ( | |
| 14 | + name: string, | |
| 15 | + verdict: EventResult<'tool.check'>, | |
| 16 | + tier?: Plugin['tier'], | |
| 17 | +): Plugin => ({ | |
| 18 | + name, | |
| 19 | + tier, | |
| 20 | + register(on) { | |
| 21 | + on('tool.check', async ($, e, next) => { | |
| 22 | + await next(e) | |
| 23 | + | |
| 24 | + return verdict | |
| 25 | + }) | |
| 26 | + }, | |
| 27 | +}) |
| @@ -1,0 +1,9 @@ | ||
| 1 | +import type { EventResult } from 'claude-code' | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * The engine's verdict when nothing allows or denies the call: ask. | |
| 5 | + */ | |
| 6 | +export const ASKED: EventResult<'tool.check'> = { | |
| 7 | + decision: 'ask', | |
| 8 | + reason: 'Bash needs approval', | |
| 9 | +} |
| @@ -1,0 +1,23 @@ | ||
| 1 | +import type { EventResult } from 'claude-code' | |
| 2 | +import type { Plugin } from 'claude-code/testing' | |
| 3 | + | |
| 4 | +/** | |
| 5 | + * A plugin that answers `tool.check` without calling `next`, so nothing | |
| 6 | + * beneath it runs: the person's own by default. | |
| 7 | + * | |
| 8 | + * @param name the plugin's name | |
| 9 | + * @param verdict what it answers | |
| 10 | + * @param tier the tier it loads in | |
| 11 | + * @returns the plugin | |
| 12 | + */ | |
| 13 | +export const blindAnswering = ( | |
| 14 | + name: string, | |
| 15 | + verdict: EventResult<'tool.check'>, | |
| 16 | + tier?: Plugin['tier'], | |
| 17 | +): Plugin => ({ | |
| 18 | + name, | |
| 19 | + tier, | |
| 20 | + register(on) { | |
| 21 | + on('tool.check', () => verdict) | |
| 22 | + }, | |
| 23 | +}) |
| @@ -1,0 +1,9 @@ | ||
| 1 | +import type { Args } from 'claude-code' | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * The question the engine puts to `tool.check`: may Bash run `echo x`. | |
| 5 | + */ | |
| 6 | +export const CHECKED: Args<'tool.check'> = { | |
| 7 | + tool: 'Bash', | |
| 8 | + input: { command: 'echo x' }, | |
| 9 | +} |
| @@ -1,0 +1,21 @@ | ||
| 1 | +import type { EventResult, On } from 'claude-code' | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * Answers every `tool.check` beneath the plugins with the verdict given, as | |
| 5 | + * the engine's own evaluation would, counting the evaluations. | |
| 6 | + * | |
| 7 | + * @param on the test's `on` | |
| 8 | + * @param verdict what the engine's evaluation decides | |
| 9 | + * @returns how many evaluations ran so far | |
| 10 | + */ | |
| 11 | +export function checksAnswered(on: On, verdict: EventResult<'tool.check'>) { | |
| 12 | + let evaluations = 0 | |
| 13 | + | |
| 14 | + on('tool.check', () => { | |
| 15 | + evaluations += 1 | |
| 16 | + | |
| 17 | + return verdict | |
| 18 | + }) | |
| 19 | + | |
| 20 | + return () => evaluations | |
| 21 | +} |
| @@ -1,0 +1,13 @@ | ||
| 1 | +export * from './allowed.js' | |
| 2 | +export * from './answering.js' | |
| 3 | +export * from './asked.js' | |
| 4 | +export * from './blind-answering.js' | |
| 5 | +export * from './checked.js' | |
| 6 | +export * from './checks-answered.js' | |
| 7 | +export * from './link-of.js' | |
| 8 | +export * from './override-policy-of.js' | |
| 9 | +export * from './plain-deny.js' | |
| 10 | +export * from './rewriting.js' | |
| 11 | +export * from './rule-deny.js' | |
| 12 | + | |
| 13 | +export * as default from '.' |
| @@ -1,0 +1,27 @@ | ||
| 1 | +import type { EventResult, Tier, TraceEntry } from 'claude-code' | |
| 2 | + | |
| 3 | +import { CHECKED } from './checked.js' | |
| 4 | + | |
| 5 | +/** | |
| 6 | + * One settled link of a `tool.check` run as `next.trace` lists it: whose | |
| 7 | + * hook, in which tier, and what it settled on (nothing when it was skipped). | |
| 8 | + * | |
| 9 | + * @param plugin the hook's plugin | |
| 10 | + * @param tier the tier the engine pinned for it | |
| 11 | + * @param returned what the link settled on | |
| 12 | + * @returns the entry | |
| 13 | + */ | |
| 14 | +export const linkOf = ( | |
| 15 | + plugin: string, | |
| 16 | + tier: Tier, | |
| 17 | + returned?: EventResult<'tool.check'>, | |
| 18 | +): TraceEntry<'tool.check'> => ({ | |
| 19 | + index: 0, | |
| 20 | + plugin, | |
| 21 | + tier, | |
| 22 | + event: 'tool.check', | |
| 23 | + outcome: returned === undefined ? 'skipped' : 'returned', | |
| 24 | + ms: 0, | |
| 25 | + received: CHECKED, | |
| 26 | + returned, | |
| 27 | +}) |
| @@ -1,0 +1,16 @@ | ||
| 1 | +import type { Settings } from 'claude-code' | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * Settings that carry this plugin's own `allowModsToOverrideDenyRules` | |
| 5 | + * option with the value given, where managed settings hold it. | |
| 6 | + * | |
| 7 | + * @param value what the option is set to | |
| 8 | + * @returns the settings | |
| 9 | + */ | |
| 10 | +export const overridePolicyOf = (value: unknown): Settings => ({ | |
| 11 | + pluginConfigs: { | |
| 12 | + 'cc-plugin-sec-default@builtin': { | |
| 13 | + options: { allowModsToOverrideDenyRules: value }, | |
| 14 | + }, | |
| 15 | + }, | |
| 16 | +}) |
| @@ -1,0 +1,10 @@ | ||
| 1 | +import type { EventResult } from 'claude-code' | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * A deny that no settings rule decided (a settings hook's, a tool's own | |
| 5 | + * check): it names no rule. | |
| 6 | + */ | |
| 7 | +export const PLAIN_DENY: EventResult<'tool.check'> = { | |
| 8 | + decision: 'deny', | |
| 9 | + reason: 'a PreToolUse hook refused it', | |
| 10 | +} |
| @@ -1,0 +1,16 @@ | ||
| 1 | +import type { Plugin } from 'claude-code/testing' | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * A plugin the person installed that asks beneath it about another command | |
| 5 | + * than the one being decided, then allows the call. | |
| 6 | + */ | |
| 7 | +export const rewriting: Plugin = { | |
| 8 | + name: 'rewriting', | |
| 9 | + register(on) { | |
| 10 | + on('tool.check', async ($, e, next) => { | |
| 11 | + await next({ ...e, input: { command: 'ls' } }) | |
| 12 | + | |
| 13 | + return { decision: 'allow' } | |
| 14 | + }) | |
| 15 | + }, | |
| 16 | +} |
| @@ -1,0 +1,11 @@ | ||
| 1 | +import type { EventResult } from 'claude-code' | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * The engine's verdict when a deny rule in some settings file matched the | |
| 5 | + * call: the deny, its sentence, and the rule as written. | |
| 6 | + */ | |
| 7 | +export const RULE_DENY: EventResult<'tool.check'> = { | |
| 8 | + decision: 'deny', | |
| 9 | + reason: 'Permission to use Bash has been denied.', | |
| 10 | + rule: 'Bash(echo *)', | |
| 11 | +} |
| @@ -1,0 +1,54 @@ | ||
| 1 | +import { describe, expect, test, tier } from 'claude-code/testing' | |
| 2 | + | |
| 3 | +import Hooks from '../hooks' | |
| 4 | +import Fixtures from './fixtures' | |
| 5 | + | |
| 6 | +tier('prepend') | |
| 7 | + | |
| 8 | +describe('held-verdict', () => { | |
| 9 | + test('a deny that names its rule is a rule deny; any other is not', () => { | |
| 10 | + expect( | |
| 11 | + [Fixtures.RULE_DENY, Fixtures.PLAIN_DENY, Fixtures.ASKED].map( | |
| 12 | + Hooks.isRuleDeny, | |
| 13 | + ), | |
| 14 | + ).toEqual([true, false, false]) | |
| 15 | + }) | |
| 16 | + | |
| 17 | + test("only the person's links that answered allow or ask are named", () => { | |
| 18 | + expect( | |
| 19 | + Hooks.loosenedByUsers([ | |
| 20 | + Fixtures.linkOf('guard', 'prepend', Fixtures.ALLOWED), | |
| 21 | + Fixtures.linkOf('easy', 'user', Fixtures.ALLOWED), | |
| 22 | + Fixtures.linkOf('strict', 'user', Fixtures.PLAIN_DENY), | |
| 23 | + Fixtures.linkOf('passed-over', 'user'), | |
| 24 | + Fixtures.linkOf('asking', 'user', Fixtures.ASKED), | |
| 25 | + Fixtures.linkOf('bundled', 'builtin', Fixtures.ALLOWED), | |
| 26 | + Fixtures.linkOf('engine', 'core', Fixtures.RULE_DENY), | |
| 27 | + ]), | |
| 28 | + ).toEqual(['easy', 'asking']) | |
| 29 | + }) | |
| 30 | + | |
| 31 | + test('the handler keeps a deny, and a verdict none of theirs made', () => { | |
| 32 | + const pastUsers = [ | |
| 33 | + Fixtures.linkOf('easy', 'user'), | |
| 34 | + Fixtures.linkOf('engine', 'core', Fixtures.ASKED), | |
| 35 | + ] | |
| 36 | + | |
| 37 | + expect([ | |
| 38 | + Hooks.caughtAnswer(Fixtures.RULE_DENY, [ | |
| 39 | + Fixtures.linkOf('easy', 'user', Fixtures.ALLOWED), | |
| 40 | + ]), | |
| 41 | + Hooks.caughtAnswer(Fixtures.ASKED, pastUsers), | |
| 42 | + ]).toEqual([Fixtures.RULE_DENY, Fixtures.ASKED]) | |
| 43 | + }) | |
| 44 | + | |
| 45 | + test('the handler refuses a verdict they loosened, and no verdict', () => { | |
| 46 | + expect([ | |
| 47 | + Hooks.caughtAnswer(Fixtures.ALLOWED, [ | |
| 48 | + Fixtures.linkOf('easy', 'user', Fixtures.ALLOWED), | |
| 49 | + Fixtures.linkOf('engine', 'core', Fixtures.ASKED), | |
| 50 | + ]), | |
| 51 | + Hooks.caughtAnswer(undefined, []), | |
| 52 | + ]).toEqual([Hooks.UNCHECKED_DENY, Hooks.UNCHECKED_DENY]) | |
| 53 | + }) | |
| 54 | +}) |
| @@ -1,0 +1,24 @@ | ||
| 1 | +import { describe, expect, test, tier } from 'claude-code/testing' | |
| 2 | + | |
| 3 | +import Policy from '../../hooks/policy' | |
| 4 | +import Fixtures from '../fixtures' | |
| 5 | + | |
| 6 | +tier('prepend') | |
| 7 | + | |
| 8 | +describe('deny-rules-hold', () => { | |
| 9 | + test('deny rules hold unless the option is the literal true', () => { | |
| 10 | + expect( | |
| 11 | + [true, 'true', 1, false, undefined].map(value => | |
| 12 | + Policy.denyRulesHold(Fixtures.overridePolicyOf(value)), | |
| 13 | + ), | |
| 14 | + ).toEqual([false, true, true, true, true]) | |
| 15 | + }) | |
| 16 | + | |
| 17 | + test('settings with no entry for this plugin hold them', () => { | |
| 18 | + expect( | |
| 19 | + [Fixtures.MANAGED_POLICY, {}, { pluginConfigs: 'x' }].map( | |
| 20 | + Policy.denyRulesHold, | |
| 21 | + ), | |
| 22 | + ).toEqual([true, true, true]) | |
| 23 | + }) | |
| 24 | +}) |
| @@ -306,4 +306,311 @@ | ||
| 306 | 306 | ) |
| 307 | 307 | }, |
| 308 | 308 | ) |
| 309 | + | |
| 310 | + test( | |
| 311 | + 'a deny rule holds over an allow from a plugin the person installed', | |
| 312 | + { plugins: [Fixtures.answering('easy', Fixtures.ALLOWED)] }, | |
| 313 | + async ($, on) => { | |
| 314 | + on('settings.read', () => ({ value: Fixtures.MANAGED_POLICY })) | |
| 315 | + | |
| 316 | + const lines = Fixtures.logged(on) | |
| 317 | + | |
| 318 | + Fixtures.checksAnswered(on, Fixtures.RULE_DENY) | |
| 319 | + | |
| 320 | + expect(await $.tool.check(Fixtures.CHECKED)).toEqual(Fixtures.RULE_DENY) | |
| 321 | + | |
| 322 | + expect(lines).toEqual([ | |
| 323 | + `transcript: ${Hooks.heldNotice('easy', 'Bash', 'Bash(echo *)')}`, | |
| 324 | + ]) | |
| 325 | + }, | |
| 326 | + ) | |
| 327 | + | |
| 328 | + test( | |
| 329 | + 'two plugins of the person chained are each named once in a session', | |
| 330 | + { | |
| 331 | + plugins: [ | |
| 332 | + Fixtures.answering('first', Fixtures.ALLOWED), | |
| 333 | + Fixtures.answering('second', Fixtures.ASKED), | |
| 334 | + ], | |
| 335 | + }, | |
| 336 | + async ($, on) => { | |
| 337 | + on('settings.read', () => ({ value: Fixtures.MANAGED_POLICY })) | |
| 338 | + | |
| 339 | + const lines = Fixtures.logged(on) | |
| 340 | + | |
| 341 | + Fixtures.checksAnswered(on, Fixtures.RULE_DENY) | |
| 342 | + | |
| 343 | + expect(await $.tool.check(Fixtures.CHECKED)).toEqual(Fixtures.RULE_DENY) | |
| 344 | + expect(await $.tool.check(Fixtures.CHECKED)).toEqual(Fixtures.RULE_DENY) | |
| 345 | + | |
| 346 | + expect(lines).toEqual([ | |
| 347 | + `transcript: ${Hooks.heldNotice('first', 'Bash', 'Bash(echo *)')}`, | |
| 348 | + `transcript: ${Hooks.heldNotice('second', 'Bash', 'Bash(echo *)')}`, | |
| 349 | + ]) | |
| 350 | + }, | |
| 351 | + ) | |
| 352 | + | |
| 353 | + test( | |
| 354 | + 'a deny rule turned into an ask by a plugin of the person holds too', | |
| 355 | + { plugins: [Fixtures.answering('easy', Fixtures.ASKED)] }, | |
| 356 | + async ($, on) => { | |
| 357 | + on('settings.read', () => ({ value: Fixtures.MANAGED_POLICY })) | |
| 358 | + Fixtures.logged(on) | |
| 359 | + Fixtures.checksAnswered(on, Fixtures.RULE_DENY) | |
| 360 | + | |
| 361 | + expect(await $.tool.check(Fixtures.CHECKED)).toEqual(Fixtures.RULE_DENY) | |
| 362 | + }, | |
| 363 | + ) | |
| 364 | + | |
| 365 | + test( | |
| 366 | + "an organization plugin's allow over a deny rule stands, above or below", | |
| 367 | + { | |
| 368 | + plugins: [ | |
| 369 | + Fixtures.answering('suite', Fixtures.ALLOWED, 'append'), | |
| 370 | + Fixtures.answering('easy', Fixtures.ALLOWED), | |
| 371 | + ], | |
| 372 | + }, | |
| 373 | + async ($, on) => { | |
| 374 | + on('settings.read', () => ({ value: Fixtures.MANAGED_POLICY })) | |
| 375 | + | |
| 376 | + const lines = Fixtures.logged(on) | |
| 377 | + | |
| 378 | + Fixtures.checksAnswered(on, Fixtures.RULE_DENY) | |
| 379 | + | |
| 380 | + expect(await $.tool.check(Fixtures.CHECKED)).toEqual(Fixtures.ALLOWED) | |
| 381 | + expect(lines).toEqual([]) | |
| 382 | + }, | |
| 383 | + ) | |
| 384 | + | |
| 385 | + test( | |
| 386 | + "a prepended organization plugin's allow over a deny rule stands", | |
| 387 | + { plugins: [Fixtures.answering('guard', Fixtures.ALLOWED, 'prepend')] }, | |
| 388 | + async ($, on) => { | |
| 389 | + on('settings.read', () => ({ value: Fixtures.MANAGED_POLICY })) | |
| 390 | + | |
| 391 | + const lines = Fixtures.logged(on) | |
| 392 | + | |
| 393 | + Fixtures.checksAnswered(on, Fixtures.RULE_DENY) | |
| 394 | + | |
| 395 | + expect(await $.tool.check(Fixtures.CHECKED)).toEqual(Fixtures.ALLOWED) | |
| 396 | + expect(lines).toEqual([]) | |
| 397 | + }, | |
| 398 | + ) | |
| 399 | + | |
| 400 | + test( | |
| 401 | + "a built-in's allow over a deny rule stands", | |
| 402 | + { plugins: [Fixtures.answering('bundled', Fixtures.ALLOWED, 'builtin')] }, | |
| 403 | + async ($, on) => { | |
| 404 | + on('settings.read', () => ({ value: Fixtures.MANAGED_POLICY })) | |
| 405 | + | |
| 406 | + const lines = Fixtures.logged(on) | |
| 407 | + | |
| 408 | + Fixtures.checksAnswered(on, Fixtures.RULE_DENY) | |
| 409 | + | |
| 410 | + expect(await $.tool.check(Fixtures.CHECKED)).toEqual(Fixtures.ALLOWED) | |
| 411 | + expect(lines).toEqual([]) | |
| 412 | + }, | |
| 413 | + ) | |
| 414 | + | |
| 415 | + test( | |
| 416 | + 'an ask a plugin of the person allows, no deny rule behind it, stands', | |
| 417 | + { plugins: [Fixtures.answering('easy', Fixtures.ALLOWED)] }, | |
| 418 | + async ($, on) => { | |
| 419 | + on('settings.read', () => ({ value: Fixtures.MANAGED_POLICY })) | |
| 420 | + | |
| 421 | + const lines = Fixtures.logged(on) | |
| 422 | + | |
| 423 | + Fixtures.checksAnswered(on, Fixtures.ASKED) | |
| 424 | + | |
| 425 | + expect(await $.tool.check(Fixtures.CHECKED)).toEqual(Fixtures.ALLOWED) | |
| 426 | + expect(lines).toEqual([]) | |
| 427 | + }, | |
| 428 | + ) | |
| 429 | + | |
| 430 | + test( | |
| 431 | + 'a deny no rule decided is still theirs to answer over', | |
| 432 | + { plugins: [Fixtures.answering('easy', Fixtures.ALLOWED)] }, | |
| 433 | + async ($, on) => { | |
| 434 | + on('settings.read', () => ({ value: Fixtures.MANAGED_POLICY })) | |
| 435 | + | |
| 436 | + const lines = Fixtures.logged(on) | |
| 437 | + | |
| 438 | + Fixtures.checksAnswered(on, Fixtures.PLAIN_DENY) | |
| 439 | + | |
| 440 | + expect(await $.tool.check(Fixtures.CHECKED)).toEqual(Fixtures.ALLOWED) | |
| 441 | + expect(lines).toEqual([]) | |
| 442 | + }, | |
| 443 | + ) | |
| 444 | + | |
| 445 | + test( | |
| 446 | + 'a plugin of the person that tightens is heard, with one evaluation', | |
| 447 | + { plugins: [Fixtures.answering('strict', Fixtures.PLAIN_DENY)] }, | |
| 448 | + async ($, on) => { | |
| 449 | + const reads = Fixtures.policyReads(on, Fixtures.MANAGED_POLICY) | |
| 450 | + const evaluations = Fixtures.checksAnswered(on, Fixtures.ASKED) | |
| 451 | + | |
| 452 | + expect(await $.tool.check(Fixtures.CHECKED)).toEqual(Fixtures.PLAIN_DENY) | |
| 453 | + | |
| 454 | + expect({ reads: reads(), evaluations: evaluations() }).toEqual({ | |
| 455 | + reads: 0, | |
| 456 | + evaluations: 1, | |
| 457 | + }) | |
| 458 | + }, | |
| 459 | + ) | |
| 460 | + | |
| 461 | + test('with none of their plugins the verdict passes once', async ($, on) => { | |
| 462 | + const reads = Fixtures.policyReads(on, Fixtures.MANAGED_POLICY) | |
| 463 | + const evaluations = Fixtures.checksAnswered(on, Fixtures.RULE_DENY) | |
| 464 | + | |
| 465 | + expect(await $.tool.check(Fixtures.CHECKED)).toEqual(Fixtures.RULE_DENY) | |
| 466 | + | |
| 467 | + expect({ reads: reads(), evaluations: evaluations() }).toEqual({ | |
| 468 | + reads: 0, | |
| 469 | + evaluations: 1, | |
| 470 | + }) | |
| 471 | + }) | |
| 472 | + | |
| 473 | + test( | |
| 474 | + 'an allow that never called next meets the deny rule all the same', | |
| 475 | + { plugins: [Fixtures.blindAnswering('blind', Fixtures.ALLOWED)] }, | |
| 476 | + async ($, on) => { | |
| 477 | + on('settings.read', () => ({ value: Fixtures.MANAGED_POLICY })) | |
| 478 | + | |
| 479 | + const lines = Fixtures.logged(on) | |
| 480 | + const evaluations = Fixtures.checksAnswered(on, Fixtures.RULE_DENY) | |
| 481 | + | |
| 482 | + expect(await $.tool.check(Fixtures.CHECKED)).toEqual(Fixtures.RULE_DENY) | |
| 483 | + | |
| 484 | + expect({ lines, evaluations: evaluations() }).toEqual({ | |
| 485 | + lines: [ | |
| 486 | + `transcript: ${Hooks.heldNotice('blind', 'Bash', 'Bash(echo *)')}`, | |
| 487 | + ], | |
| 488 | + evaluations: 1, | |
| 489 | + }) | |
| 490 | + }, | |
| 491 | + ) | |
| 492 | + | |
| 493 | + test( | |
| 494 | + 'a rule a plugin of the person writes into its answer is never read', | |
| 495 | + { | |
| 496 | + plugins: [ | |
| 497 | + Fixtures.answering('forging', { | |
| 498 | + decision: 'ask', | |
| 499 | + reason: 'fine by me', | |
| 500 | + rule: 'Bash(ls *)', | |
| 501 | + }), | |
| 502 | + ], | |
| 503 | + }, | |
| 504 | + async ($, on) => { | |
| 505 | + on('settings.read', () => ({ value: Fixtures.MANAGED_POLICY })) | |
| 506 | + | |
| 507 | + const lines = Fixtures.logged(on) | |
| 508 | + | |
| 509 | + Fixtures.checksAnswered(on, Fixtures.RULE_DENY) | |
| 510 | + | |
| 511 | + expect(await $.tool.check(Fixtures.CHECKED)).toEqual(Fixtures.RULE_DENY) | |
| 512 | + | |
| 513 | + expect(lines).toEqual([ | |
| 514 | + `transcript: ${Hooks.heldNotice('forging', 'Bash', 'Bash(echo *)')}`, | |
| 515 | + ]) | |
| 516 | + }, | |
| 517 | + ) | |
| 518 | + | |
| 519 | + test( | |
| 520 | + 'a plugin of the person asking about another command is left out', | |
| 521 | + { plugins: [Fixtures.rewriting] }, | |
| 522 | + async ($, on) => { | |
| 523 | + on('settings.read', () => ({ value: Fixtures.MANAGED_POLICY })) | |
| 524 | + Fixtures.logged(on) | |
| 525 | + | |
| 526 | + const asked: unknown[] = [] | |
| 527 | + | |
| 528 | + on('tool.check', ($, e) => { | |
| 529 | + asked.push(e.input) | |
| 530 | + | |
| 531 | + return Fixtures.RULE_DENY | |
| 532 | + }) | |
| 533 | + | |
| 534 | + expect(await $.tool.check(Fixtures.CHECKED)).toEqual(Fixtures.RULE_DENY) | |
| 535 | + | |
| 536 | + expect( | |
| 537 | + asked, | |
| 538 | + 'the rules were asked about the command that would run, only', | |
| 539 | + ).toEqual([Fixtures.CHECKED.input]) | |
| 540 | + }, | |
| 541 | + ) | |
| 542 | + | |
| 543 | + test( | |
| 544 | + 'managed settings may let the plugins a person installs override', | |
| 545 | + { plugins: [Fixtures.answering('easy', Fixtures.ALLOWED)] }, | |
| 546 | + async ($, on) => { | |
| 547 | + on('settings.read', () => ({ value: Fixtures.overridePolicyOf(true) })) | |
| 548 | + | |
| 549 | + const lines = Fixtures.logged(on) | |
| 550 | + const evaluations = Fixtures.checksAnswered(on, Fixtures.RULE_DENY) | |
| 551 | + | |
| 552 | + expect(await $.tool.check(Fixtures.CHECKED)).toEqual(Fixtures.ALLOWED) | |
| 553 | + | |
| 554 | + expect({ lines, evaluations: evaluations() }).toEqual({ | |
| 555 | + lines: [], | |
| 556 | + evaluations: 1, | |
| 557 | + }) | |
| 558 | + }, | |
| 559 | + ) | |
| 560 | + | |
| 561 | + test( | |
| 562 | + "only managed settings are asked: a person's own cannot lift a deny rule", | |
| 563 | + { plugins: [Fixtures.answering('easy', Fixtures.ALLOWED)] }, | |
| 564 | + async ($, on) => { | |
| 565 | + Fixtures.policyBySource( | |
| 566 | + on, | |
| 567 | + Fixtures.MANAGED_POLICY, | |
| 568 | + Fixtures.overridePolicyOf(true), | |
| 569 | + ) | |
| 570 | + | |
| 571 | + Fixtures.logged(on) | |
| 572 | + Fixtures.checksAnswered(on, Fixtures.RULE_DENY) | |
| 573 | + | |
| 574 | + expect(await $.tool.check(Fixtures.CHECKED)).toEqual(Fixtures.RULE_DENY) | |
| 575 | + }, | |
| 576 | + ) | |
| 577 | + | |
| 578 | + test( | |
| 579 | + 'the option mistyped lifts nothing: only the literal true does', | |
| 580 | + { plugins: [Fixtures.answering('easy', Fixtures.ALLOWED)] }, | |
| 581 | + async ($, on) => { | |
| 582 | + on('settings.read', () => ({ value: Fixtures.overridePolicyOf('true') })) | |
| 583 | + Fixtures.logged(on) | |
| 584 | + Fixtures.checksAnswered(on, Fixtures.RULE_DENY) | |
| 585 | + | |
| 586 | + expect(await $.tool.check(Fixtures.CHECKED)).toEqual(Fixtures.RULE_DENY) | |
| 587 | + }, | |
| 588 | + ) | |
| 589 | + | |
| 590 | + test( | |
| 591 | + 'with a policy that cannot be read the deny rule holds: fails closed', | |
| 592 | + { plugins: [Fixtures.answering('easy', Fixtures.ALLOWED)] }, | |
| 593 | + async ($, on) => { | |
| 594 | + on('settings.read', () => ({ deny: 'managed settings unreadable' })) | |
| 595 | + Fixtures.logged(on) | |
| 596 | + Fixtures.checksAnswered(on, Fixtures.RULE_DENY) | |
| 597 | + | |
| 598 | + expect(await $.tool.check(Fixtures.CHECKED)).toEqual(Fixtures.RULE_DENY) | |
| 599 | + }, | |
| 600 | + ) | |
| 601 | + | |
| 602 | + test( | |
| 603 | + "when the rules cannot be evaluated the call is refused: the hook's catch", | |
| 604 | + { plugins: [Fixtures.blindAnswering('blind', Fixtures.ALLOWED)] }, | |
| 605 | + async ($, on) => { | |
| 606 | + on('settings.read', () => ({ value: Fixtures.MANAGED_POLICY })) | |
| 607 | + Fixtures.logged(on) | |
| 608 | + | |
| 609 | + on('tool.check', () => { | |
| 610 | + throw new Error('the evaluation failed') | |
| 611 | + }) | |
| 612 | + | |
| 613 | + expect(await $.tool.check(Fixtures.CHECKED)).toEqual(Hooks.UNCHECKED_DENY) | |
| 614 | + }, | |
| 615 | + ) | |
| 309 | 616 | }) |