spacehub

surshar / claude-code

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

Built by Anthropic · github.com/anthropics

sec-default: a settings deny rule holds over an allow or ask from a plugin the person installedBrowse files
poteat authored 24 days ago parent dec92bc · commit a92b0c5bae9b
40 changed files+912 -12compared with dec92bc
Mmods/README.md
Mmods/sec-default/README.md
Amods/sec-default/hooks/held-verdict/caught-answer.ts
Amods/sec-default/hooks/held-verdict/held-notice.ts
Amods/sec-default/hooks/held-verdict/index.ts
Amods/sec-default/hooks/held-verdict/verdicts/index.ts
Amods/sec-default/hooks/held-verdict/verdicts/is-rule-deny.ts
Amods/sec-default/hooks/held-verdict/verdicts/loosened-by-users.ts
Amods/sec-default/hooks/held-verdict/verdicts/types/index.ts
Amods/sec-default/hooks/held-verdict/verdicts/types/rule-deny.ts
Amods/sec-default/hooks/held-verdict/verdicts/unchecked-deny.ts
Mmods/sec-default/hooks/hooks.json
Mmods/sec-default/hooks/index.ts
Amods/sec-default/hooks/policy/deny-rules-hold.ts
Mmods/sec-default/hooks/policy/index.ts
Amods/sec-default/hooks/policy/own-option/index.ts
Amods/sec-default/hooks/policy/own-option/member-of/index.ts
Amods/sec-default/hooks/policy/own-option/member-of/member-of.ts
Amods/sec-default/hooks/policy/own-option/own-option.ts
Amods/sec-default/hooks/policy/own-option/own-options-path/index.ts
Amods/sec-default/hooks/policy/own-option/own-options-path/own-options-path.ts
Mmods/sec-default/hooks/register.ts
Mmods/sec-default/tests/fixtures/index.ts
Amods/sec-default/tests/fixtures/logged.ts
Amods/sec-default/tests/fixtures/policy-by-source.ts
Amods/sec-default/tests/fixtures/tool-check/allowed.ts
Amods/sec-default/tests/fixtures/tool-check/answering.ts
Amods/sec-default/tests/fixtures/tool-check/asked.ts
Amods/sec-default/tests/fixtures/tool-check/blind-answering.ts
Amods/sec-default/tests/fixtures/tool-check/checked.ts
Amods/sec-default/tests/fixtures/tool-check/checks-answered.ts
Amods/sec-default/tests/fixtures/tool-check/index.ts
Amods/sec-default/tests/fixtures/tool-check/link-of.ts
Amods/sec-default/tests/fixtures/tool-check/override-policy-of.ts
Amods/sec-default/tests/fixtures/tool-check/plain-deny.ts
Amods/sec-default/tests/fixtures/tool-check/rewriting.ts
Amods/sec-default/tests/fixtures/tool-check/rule-deny.ts
Amods/sec-default/tests/held-verdict.test.ts
Amods/sec-default/tests/policy/deny-rules-hold.test.ts
Mmods/sec-default/tests/register.test.ts
@@ -7,7 +7,7 @@
77
88 | Mod | What it does | Seated |
99 | --- | --- | --- |
10-| [`sec-default`](sec-default) | Keeps an organization's classic hooks, prompt content, managed settings and tool policy out of reach of the plugins a person installs; adds no policy of its own. | Outermost, on a machine with managed settings or for a Team or Enterprise organization, unless managed `prependPlugins` says otherwise |
10+| [`sec-default`](sec-default) | Keeps an organization's classic hooks, prompt content, managed settings, tool policy and deny rules out of reach of the plugins a person installs; adds no policy of its own. | Outermost, on a machine with managed settings or for a Team or Enterprise organization, unless managed `prependPlugins` says otherwise |
1111 | [`diff`](diff) | `/diff`: the session's uncommitted changes in a pane beside the transcript, file by file with their hunks, refreshed as Claude edits files and runs commands. | Built in |
1212 | [`telemetry`](telemetry) | Hooks `$.telemetry`'s two events (`log`, `mark`), adding the noun in the `engine.create` fold where the engine has none, so a built-in plugin can record an event as a first-party analytics row, sent in batches; refuses installed plugins; sends nothing wherever Claude Code's analytics are off. | Built in |
1313 | [`agents-md`](agents-md) | `AGENTS.md` as project instructions, by one option: loaded where the project has no `CLAUDE.md` of its own (`claude-md-or-agents-md`, the default) or beside it (`claude-md-and-agents-md`), placed and framed exactly as the engine places `CLAUDE.md`, nested ones on a `Read`; or the project's and the person's instruction files dropped and the organization's kept (`managed-only`); or `CLAUDE.md` alone, as the engine reads it (`claude-md`). | Built in |
@@ -4,10 +4,11 @@
44 say on every event, in chain order, and the plugins a person installs sit
55 in the user tier, beneath the organization's prepend tier and above its
66 append tier. Some of what an organization sets today (its classic hooks,
7-its managed CLAUDE.md and rules, its settings, its MCP allowlist) was never
8-within a person's reach before function hooks; seated outermost, this
9-plugin keeps exactly those out of the user tier's reach and adds no policy
10-of its own. Everything else passes through untouched.
7+its managed CLAUDE.md and rules, its settings, its MCP allowlist, the deny
8+rules in force on its machines) was never within a person's reach before
9+function hooks; seated outermost, this plugin keeps exactly those out of the
10+user tier's reach and adds no policy of its own. Everything else passes
11+through untouched.
1112
1213 It has three moves and nothing else: continue past the user tier
1314 (`next.to(e, "append")`), refuse a user-tier caller by name (`{ deny }`
@@ -29,19 +30,81 @@
2930 | `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. |
3031 | `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. |
3132 | `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`. |
3335
3436 ## What it hooks
3537
3638 `classic.*`, `prompt.section`, `prompt.context`, `skill.prompt`,
3739 `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`.
3941
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+
4047 ## What it calls on `$`
4148
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.
4452
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+
45108 ## Where it is seated
46109
47110 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 @@
11 {
2- "description": "Security default: from the outermost seat, continues past the user tier on the organization's classic hooks, prompt content, settings and subjects, refuses a user-tier tool.register under an MCP allowlist, 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",
33 "modules": ["./register.ts"]
44 }
@@ -1,3 +1,4 @@
1+export * from './held-verdict'
12 export * from './past-users'
23 export * from './policy'
34 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 @@
11 export * from './create-policy-memo'
22 export * from './decided-by-policy.js'
3+export * from './deny-rules-hold.js'
34 export * from './has-mcp-allowlist.js'
45 export * from './managed-tools-restored'
6+export * from './own-option'
57 export * from './policy-memo-ms.js'
68 export * from './source.js'
79
@@ -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 @@
11 import type { On } from 'claude-code'
22
3+import HeldVerdict from './held-verdict'
34 import { pastUsers } from './past-users'
45 import Policy from './policy'
56 import { TOOL_REGISTER_REFUSAL } from './tool-register-refusal'
@@ -9,13 +10,14 @@
910 * organization has today out of reach of the plugins a person installs.
1011 *
1112 * 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.
1415 *
1516 * @param on the engine's registrar
1617 */
1718 export function register(on: On) {
1819 const readPolicy = Policy.createPolicyMemo(Policy.POLICY_MEMO_MS)
20+ const told = new Set<string>()
1921
2022 on('classic.*', ($, e, next) => next.to(e, 'append'))
2123
@@ -58,4 +60,43 @@
5860 await next(e),
5961 ),
6062 )
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+ })
61102 }
@@ -6,12 +6,14 @@
66 export * from './dropping.js'
77 export * from './fullscreen.js'
88 export * from './listing.js'
9+export * from './logged.js'
910 export * from './managed-policy.js'
1011 export * from './marking.js'
1112 export * from './memory.js'
1213 export * from './no-allowlist.js'
1314 export * from './odd-providers.js'
1415 export * from './org-providers.js'
16+export * from './policy-by-source.js'
1517 export * from './policy-command.js'
1618 export * from './policy-reads.js'
1719 export * from './reading.js'
@@ -23,6 +25,7 @@
2325 export * from './signing.js'
2426 export * from './stripping.js'
2527 export * from './subjects-echoed.js'
28+export * from './tool-check'
2629 export * from './tool-described.js'
2730 export * from './tools.js'
2831 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 @@
306306 )
307307 },
308308 )
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+ )
309616 })