mirror of
https://github.com/anthropics/claude-code.git
synced 2026-10-02 05:25:05 +08:00
sec-default: a settings deny rule holds over an allow or ask from a plugin the person installed (#98080)
* sec-default: a settings deny rule holds over an allow or ask from a plugin the person installed * sec-default: the tool.check test plugins carry their verdicts in their own bodies * sec-default: a user-tier link counts as loosening only when it answers looser than it was handed * sec-default: the README says where the line lands in a plain -p run * sec-default: a batch listed under prepend may hold a person's plugin, and the line says lift * sec-default: the README and one doc comment say what the batch fix changed
This commit is contained in:
+1
-1
@@ -7,7 +7,7 @@ source, published as it is built into the binary.
|
||||
|
||||
| Mod | What it does | Seated |
|
||||
| --- | --- | --- |
|
||||
| [`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 |
|
||||
| [`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 |
|
||||
| [`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 |
|
||||
| [`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 |
|
||||
| [`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 @@ The security default for organizations. Function hooks give every plugin a
|
||||
say on every event, in chain order, and the plugins a person installs sit
|
||||
in the user tier, beneath the organization's prepend tier and above its
|
||||
append tier. Some of what an organization sets today (its classic hooks,
|
||||
its managed CLAUDE.md and rules, its settings, its MCP allowlist) was never
|
||||
within a person's reach before function hooks; seated outermost, this
|
||||
plugin keeps exactly those out of the user tier's reach and adds no policy
|
||||
of its own. Everything else passes through untouched.
|
||||
its managed CLAUDE.md and rules, its settings, its MCP allowlist, the deny
|
||||
rules in force on its machines) was never within a person's reach before
|
||||
function hooks; seated outermost, this plugin keeps exactly those out of the
|
||||
user tier's reach and adds no policy of its own. Everything else passes
|
||||
through untouched.
|
||||
|
||||
It has three moves and nothing else: continue past the user tier
|
||||
(`next.to(e, "append")`), refuse a user-tier caller or module by name
|
||||
@@ -30,12 +31,13 @@ settings it decides by.
|
||||
| `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. |
|
||||
| `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. |
|
||||
| `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. |
|
||||
| `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. |
|
||||
| `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. |
|
||||
| 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`. |
|
||||
| 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`. |
|
||||
|
||||
## Options an administrator sets
|
||||
|
||||
One, in managed settings, under this plugin's own `pluginConfigs` entry,
|
||||
Two, in managed settings, under this plugin's own `pluginConfigs` entry,
|
||||
keyed by the id the CLI builds the plugin in under (only this spelling of the
|
||||
id is read):
|
||||
|
||||
@@ -82,17 +84,95 @@ not loaded. Settings hooks, status lines and `/goal` are not touched by it.
|
||||
- `claude plugin test` is not covered: it runs a mod's tests in an engine of
|
||||
their own and loads nothing into a session.
|
||||
|
||||
`allowModsToOverrideDenyRules`: the plugins a person installs may answer
|
||||
over a settings deny rule on `tool.check`, as they could before this plugin
|
||||
held deny rules. Off unless it is the literal `true`; an option that reads
|
||||
as unset leaves deny rules holding. See [Deny rules hold](#deny-rules-hold).
|
||||
|
||||
## What it hooks
|
||||
|
||||
`classic.*`, `prompt.section`, `prompt.context`, `skill.prompt`,
|
||||
`attribution.text`, `settings.read`, `tool.describe`, `command.describe`,
|
||||
`agent.offer`, `agent.spawn`, `tool.register`, `tool.list`,
|
||||
`agent.offer`, `agent.spawn`, `tool.register`, `tool.list`, `tool.check`,
|
||||
`plugin.register`.
|
||||
|
||||
Hooking `tool.check` has a cost: the engine raises that event only when some
|
||||
loaded plugin hooks it, so where this plugin is seated every tool call now
|
||||
runs the `tool.check` chain, where before only a session with such a plugin
|
||||
did.
|
||||
|
||||
## What it calls on `$`
|
||||
|
||||
`settings.read`, and `ui.log` to the debug log. It continues to the `append`
|
||||
tier with `next.to`, which only a plugin in a managed tier may do.
|
||||
`settings.read`, and `ui.log`: to the debug log, and for the one line a
|
||||
person reads when a deny rule held over a plugin of theirs. It continues to
|
||||
the `append` tier with `next.to`, which only a plugin in a managed tier may
|
||||
do.
|
||||
|
||||
## Deny rules hold
|
||||
|
||||
On `tool.check` any hook may answer any verdict, so a plugin a person
|
||||
installs to stop the permission prompts (`() => ({ decision: "allow" })`)
|
||||
would also lift a deny rule, a managed one included. Where this plugin is
|
||||
seated it does not:
|
||||
|
||||
- The hook first runs the chain as it is. If the answer is a deny, or no
|
||||
link that may hold a person's plugin answered more permissively than the
|
||||
verdict handed up to it, the answer passes: nothing of the person's
|
||||
loosened anything, and a plugin that only listens adds no run of its own.
|
||||
This is read off `next.trace`, whose tiers the engine pins: a link that never
|
||||
called `next` is measured against a deny, and since the engine lists
|
||||
neighbouring plugins that share a worker as one batch under its first
|
||||
member's tier, a `prepend` entry beneath this plugin counts as well as a
|
||||
`user` one. Whether a person's plugin did the loosening is never settled
|
||||
here, only by the next step.
|
||||
- Otherwise it runs the dispatch once more with the user tier left out
|
||||
(`next.to(e, "append")`). That verdict never passed through a person's
|
||||
plugin, so neither the decision nor the rule it names can have been
|
||||
rewritten or erased, and a plugin that answered without calling `next`
|
||||
changes nothing: the rules are evaluated in this run. The two runs differ
|
||||
by the user tier alone, so a deny here that names its rule is a deny rule
|
||||
the user tier loosened, and it is returned in place of the chain's answer.
|
||||
- Any deny rule counts, whatever settings file it came from: a verdict
|
||||
carries the rule as written, never where it was read from. A deny that
|
||||
names no rule (a settings hook's, a tool's own check) is not held.
|
||||
- An organization's plugin (prepend or append) or a built-in that allows
|
||||
over a deny rule takes part in both runs, so its answer stands (a prepended
|
||||
one that loosens is what brings the second run about, so its hooks run
|
||||
twice on such a call). An ask
|
||||
that a person's plugin turns into an allow, with no deny rule behind it,
|
||||
stands: that is what such a plugin is for.
|
||||
- `tool.check` pins the question (`tool`, `input`, `tool_use_id`), so no hook
|
||||
can have the rules evaluated on one command and another run; a rewrite
|
||||
belongs to `tool.call`, which runs before any of this.
|
||||
- The person is told once for each name in a session, in the transcript
|
||||
and the debug log: `<plugin> tried to lift a deny rule in your settings
|
||||
from a <tool> call (<rule>); the deny rule holds over the plugins you
|
||||
install (allowModsToOverrideDenyRules)`. Plugins the engine ran as one
|
||||
batch are named together, as it names them (`audit+easy`). A plain `-p`
|
||||
run has it in the debug log alone; the call is still denied with the
|
||||
rule's own message.
|
||||
- If the hook itself fails, its `.catch` answers from the one run it can
|
||||
read: a deny stands; a verdict no plugin of the person's loosened stands;
|
||||
one they loosened, or a run that rejected, is refused, since the deny
|
||||
rules were never consulted.
|
||||
|
||||
An organization that wants the plugins its people install to override deny
|
||||
rules says so in managed settings, under this plugin's own options:
|
||||
|
||||
```json
|
||||
{
|
||||
"pluginConfigs": {
|
||||
"cc-plugin-sec-default@builtin": {
|
||||
"options": { "allowModsToOverrideDenyRules": true }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Only the managed source is read (`$.settings.read({ source: "policy" })`),
|
||||
so the same key in a person's, a project's or a local settings file, or in
|
||||
`--settings`, is never consulted; only the literal `true` counts, and a
|
||||
policy that cannot be read leaves deny rules holding.
|
||||
|
||||
## Where it is seated
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { EventResult, TraceEntry } from 'claude-code'
|
||||
|
||||
import Verdicts from './verdicts'
|
||||
|
||||
/**
|
||||
* What the `tool.check` hook's failure handler answers from the one run it
|
||||
* can read, the failed hook's last: that run's verdict, or a refusal.
|
||||
*
|
||||
* A deny stands, and so does a verdict no link that may hold a person's
|
||||
* plugin loosened. A loosened one, or none at all, met no deny rule.
|
||||
*
|
||||
* @param last what that run settled on; undefined when it rejected
|
||||
* @param trace that run's `next.trace`
|
||||
* @returns the verdict the handler returns
|
||||
*/
|
||||
export function caughtAnswer(
|
||||
last: EventResult<'tool.check'> | undefined,
|
||||
trace: readonly TraceEntry<'tool.check'>[],
|
||||
) {
|
||||
const isVouched =
|
||||
last !== undefined &&
|
||||
(last.decision === 'deny' || Verdicts.loosenedByUsers(trace).length === 0)
|
||||
|
||||
return isVouched ? last : Verdicts.UNCHECKED_DENY
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* What a person reads, once for each plugin in a session, when a plugin they
|
||||
* installed answered allow or ask over a deny rule in their settings.
|
||||
*
|
||||
* It names the option an administrator sets to let such plugins override.
|
||||
*
|
||||
* @param plugin the plugin's name, or its batch's, as the trace names it
|
||||
* @param tool the tool the call named
|
||||
* @param rule the deny rule that decided, as written
|
||||
* @returns the line
|
||||
*/
|
||||
export const heldNotice = (plugin: string, tool: string, rule: string) =>
|
||||
`${plugin} tried to lift a deny rule in your settings from a ${tool} ` +
|
||||
`call (${rule}); the deny rule holds over the plugins you install ` +
|
||||
'(allowModsToOverrideDenyRules)'
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from './caught-answer.js'
|
||||
export * from './held-notice.js'
|
||||
export * from './verdicts'
|
||||
|
||||
export * as default from '.'
|
||||
@@ -0,0 +1,7 @@
|
||||
export * from './is-rule-deny.js'
|
||||
export * from './loosened-by-users.js'
|
||||
export * from './ranking'
|
||||
export * from './types'
|
||||
export * from './unchecked-deny.js'
|
||||
|
||||
export * as default from '.'
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { EventResult } from 'claude-code'
|
||||
|
||||
import type { RuleDeny } from './types'
|
||||
|
||||
/**
|
||||
* Whether a `tool.check` verdict is a deny that a settings rule decided: the
|
||||
* one verdict the plugins a person installs may not loosen.
|
||||
*
|
||||
* Any deny rule counts, whatever settings file it came from: a verdict
|
||||
* carries the rule as written and never where it was read from.
|
||||
*
|
||||
* @param verdict what a run of the chain settled on
|
||||
* @returns true for a deny that names the rule behind it
|
||||
*/
|
||||
export const isRuleDeny = (
|
||||
verdict: EventResult<'tool.check'>,
|
||||
): verdict is RuleDeny =>
|
||||
verdict.decision === 'deny' && verdict.rule !== undefined
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { TraceEntry } from 'claude-code'
|
||||
|
||||
import Ranking from './ranking'
|
||||
|
||||
/**
|
||||
* The links of one run of `tool.check` that may hold a plugin a person
|
||||
* installed and that answered more permissively than they were handed.
|
||||
*
|
||||
* Read off `next.trace`, whose `tier` the engine pins. A batch is named as
|
||||
* the engine names it, its members joined; whether a person's plugin did
|
||||
* the loosening is settled by the run past the user tier, never here.
|
||||
*
|
||||
* @param trace what settled beneath the hook on its latest `next` call
|
||||
* @returns their names, nearest the caller first; none when none loosened
|
||||
*/
|
||||
export const loosenedByUsers = (
|
||||
trace: readonly TraceEntry<'tool.check'>[],
|
||||
): readonly string[] =>
|
||||
trace
|
||||
.filter(
|
||||
(link, at) =>
|
||||
Ranking.TIERS_HOLDING_USERS.includes(link.tier) &&
|
||||
Ranking.isLooser(link.returned, Ranking.handedTo(trace, at)),
|
||||
)
|
||||
.map(link => link.plugin)
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { TraceEntry } from 'claude-code'
|
||||
|
||||
/**
|
||||
* The verdict handed up to one link of a run: what the nearest link beneath
|
||||
* it that settled on anything settled on.
|
||||
*
|
||||
* Undefined for a link that answered without calling `next`: the trace ends
|
||||
* short of the engine there, and nothing beneath it ran.
|
||||
*
|
||||
* @param trace a run as `next.trace` lists it, nearest the caller first
|
||||
* @param at the link's place in that list
|
||||
* @returns the verdict, or undefined when nothing settled beneath the link
|
||||
*/
|
||||
export const handedTo = (
|
||||
trace: readonly TraceEntry<'tool.check'>[],
|
||||
at: number,
|
||||
) => trace.slice(at + 1).find(link => link.returned !== undefined)?.returned
|
||||
@@ -0,0 +1,6 @@
|
||||
export * from './handed-to.js'
|
||||
export * from './is-looser.js'
|
||||
export * from './leniency'
|
||||
export * from './tiers-holding-users'
|
||||
|
||||
export * as default from '.'
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { EventResult } from 'claude-code'
|
||||
|
||||
import { LENIENCY } from './leniency'
|
||||
|
||||
/**
|
||||
* Whether a link's own verdict is more permissive than the one handed up to
|
||||
* it; with nothing handed up (it never called `next`), than a deny.
|
||||
*
|
||||
* A link that settled on nothing (skipped, rejected) loosened nothing.
|
||||
*
|
||||
* @param own what the link settled on
|
||||
* @param handed what settled beneath it and was handed up, if anything did
|
||||
* @returns true when the link loosened the verdict
|
||||
*/
|
||||
export const isLooser = (
|
||||
own: EventResult<'tool.check'> | undefined,
|
||||
handed: EventResult<'tool.check'> | undefined,
|
||||
) =>
|
||||
own !== undefined &&
|
||||
LENIENCY[own.decision] > LENIENCY[handed?.decision ?? 'deny']
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './leniency.js'
|
||||
|
||||
export * as default from '.'
|
||||
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* How permissive each `tool.check` verdict is, the refusal lowest: a link
|
||||
* loosened a verdict when its own ranks above the one handed up to it.
|
||||
*/
|
||||
export const LENIENCY = Object.freeze({ deny: 0, ask: 1, allow: 2 })
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './tiers-holding-users.js'
|
||||
|
||||
export * as default from '.'
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* The tiers a trace entry can carry when a plugin a person installed ran in
|
||||
* it: `user`, and `prepend` beneath this plugin's own seat.
|
||||
*
|
||||
* The engine runs neighbouring plugins that share a worker as one batch and
|
||||
* lists the batch under its first member's tier, so an organization's
|
||||
* prepended plugin can stand for a batch that holds a person's.
|
||||
*/
|
||||
export const TIERS_HOLDING_USERS: readonly unknown[] = Object.freeze([
|
||||
'user',
|
||||
'prepend',
|
||||
])
|
||||
@@ -0,0 +1,3 @@
|
||||
export type * from './rule-deny.js'
|
||||
|
||||
export * as default from '.'
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { EventResult } from 'claude-code'
|
||||
|
||||
/**
|
||||
* A `tool.check` deny that names the settings rule behind it.
|
||||
*/
|
||||
export type RuleDeny = EventResult<'tool.check'> & {
|
||||
readonly decision: 'deny'
|
||||
readonly rule: string
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { EventResult } from 'claude-code'
|
||||
|
||||
/**
|
||||
* What the failure handler answers when a verdict was loosened, or never
|
||||
* reached, and no deny rule check vouches for it: absent counts as deny.
|
||||
*/
|
||||
export const UNCHECKED_DENY: EventResult<'tool.check'> = Object.freeze({
|
||||
decision: 'deny',
|
||||
reason:
|
||||
'the deny rules in your settings could not be checked for this call, ' +
|
||||
'so it is refused',
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"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 refuses a user-tier hooks module at plugin.register while managed settings set its allowManagedModsOnly option",
|
||||
"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",
|
||||
"modules": ["./register.ts"]
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from './admission-failure'
|
||||
export * from './held-verdict'
|
||||
export * from './managed-mods-only-refusal'
|
||||
export * from './past-users'
|
||||
export * from './policy'
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { Settings } from 'claude-code'
|
||||
|
||||
import { ownOption } from './own-option'
|
||||
|
||||
/**
|
||||
* Whether deny rules hold over the plugins a person installs: they do unless
|
||||
* managed policy sets `allowModsToOverrideDenyRules` to the literal `true`.
|
||||
*
|
||||
* A value mistyped (`"true"`, `1`) loosens nothing. Only the policy source
|
||||
* is handed in, so a person's settings never reach this.
|
||||
*
|
||||
* @param policy the managed settings, as `$.settings.read` answers them
|
||||
* @returns false only when the organization let a person's plugins override
|
||||
*/
|
||||
export const denyRulesHold = (policy: Settings) =>
|
||||
ownOption(policy, 'allowModsToOverrideDenyRules') !== true
|
||||
@@ -1,5 +1,6 @@
|
||||
export * from './create-policy-memo'
|
||||
export * from './decided-by-policy.js'
|
||||
export * from './deny-rules-hold.js'
|
||||
export * from './has-mcp-allowlist.js'
|
||||
export * from './is-managed-mods-only.js'
|
||||
export * from './managed-tools-restored'
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { On } from 'claude-code'
|
||||
|
||||
import { admissionFailure } from './admission-failure'
|
||||
import HeldVerdict from './held-verdict'
|
||||
import { managedModsOnlyRefusal } from './managed-mods-only-refusal'
|
||||
import { pastUsers } from './past-users'
|
||||
import Policy from './policy'
|
||||
@@ -18,6 +19,7 @@ import { TOOL_REGISTER_REFUSAL } from './tool-register-refusal'
|
||||
*/
|
||||
export function register(on: On) {
|
||||
const readPolicy = Policy.createPolicyMemo(Policy.POLICY_MEMO_MS)
|
||||
const told = new Set<string>()
|
||||
|
||||
on('classic.*', ($, e, next) => next.to(e, 'append'))
|
||||
|
||||
@@ -61,6 +63,45 @@ export function register(on: On) {
|
||||
),
|
||||
)
|
||||
|
||||
on('tool.check', async ($, e, next) => {
|
||||
const answer = await next(e)
|
||||
const mods = HeldVerdict.loosenedByUsers(next.trace)
|
||||
|
||||
const shouldRecheck =
|
||||
answer.decision !== 'deny' &&
|
||||
mods.length > 0 &&
|
||||
(await Policy.decidedByPolicy(
|
||||
readPolicy(() => $.settings.read(Policy.SOURCE)),
|
||||
Policy.denyRulesHold,
|
||||
))
|
||||
|
||||
if (!shouldRecheck) {
|
||||
return answer
|
||||
}
|
||||
|
||||
const held = await next.to(e, 'append')
|
||||
|
||||
if (!HeldVerdict.isRuleDeny(held)) {
|
||||
return answer
|
||||
}
|
||||
|
||||
for (const mod of mods.filter(name => !told.has(name))) {
|
||||
told.add(mod)
|
||||
$.ui.log(HeldVerdict.heldNotice(mod, e.tool, held.rule))
|
||||
}
|
||||
|
||||
return held
|
||||
}).catch(async ($, e, next) => {
|
||||
const last = await next(e).catch(() => undefined)
|
||||
|
||||
const shouldVouch = await Policy.decidedByPolicy(
|
||||
readPolicy(() => $.settings.read(Policy.SOURCE)),
|
||||
Policy.denyRulesHold,
|
||||
)
|
||||
|
||||
return shouldVouch ? HeldVerdict.caughtAnswer(last, next.trace) : last
|
||||
})
|
||||
|
||||
on('plugin.register', { tier: 'user' }, async ($, e, next) =>
|
||||
Policy.isManagedModsOnly(await $.settings.read(Policy.SOURCE))
|
||||
? { refuse: managedModsOnlyRefusal(e.name) }
|
||||
|
||||
+1
@@ -28,6 +28,7 @@ export * from './session.js'
|
||||
export * from './signing.js'
|
||||
export * from './stripping.js'
|
||||
export * from './subjects-echoed.js'
|
||||
export * from './tool-check'
|
||||
export * from './tool-described.js'
|
||||
export * from './tools.js'
|
||||
export * from './tools-command.js'
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { EventResult } from 'claude-code'
|
||||
|
||||
/**
|
||||
* What an auto-approve plugin answers every `tool.check`: allow.
|
||||
*/
|
||||
export const ALLOWED: EventResult<'tool.check'> = { decision: 'allow' }
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { Plugin } from 'claude-code/testing'
|
||||
|
||||
/**
|
||||
* A plugin that hears the verdict beneath it on `tool.check`, then allows
|
||||
* the call whatever it heard: the person's own by default.
|
||||
*
|
||||
* @param name the plugin's name
|
||||
* @param tier the tier it loads in
|
||||
* @returns the plugin
|
||||
*/
|
||||
export const allowing = (name: string, tier?: Plugin['tier']): Plugin => ({
|
||||
name,
|
||||
tier,
|
||||
register(on) {
|
||||
on('tool.check', async ($, e, next) => {
|
||||
await next(e)
|
||||
|
||||
return { decision: 'allow' }
|
||||
})
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { EventResult } from 'claude-code'
|
||||
|
||||
/**
|
||||
* The engine's verdict when nothing allows or denies the call: ask.
|
||||
*/
|
||||
export const ASKED: EventResult<'tool.check'> = {
|
||||
decision: 'ask',
|
||||
reason: 'Bash needs approval',
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { Plugin } from 'claude-code/testing'
|
||||
|
||||
/**
|
||||
* A plugin the person installed that hears the verdict beneath it on
|
||||
* `tool.check`, then puts the call to the person whatever it heard.
|
||||
*
|
||||
* @param name the plugin's name
|
||||
* @returns the plugin
|
||||
*/
|
||||
export const asking = (name: string): Plugin => ({
|
||||
name,
|
||||
register(on) {
|
||||
on('tool.check', async ($, e, next) => {
|
||||
await next(e)
|
||||
|
||||
return { decision: 'ask', reason: 'Bash needs approval' }
|
||||
})
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { Plugin } from 'claude-code/testing'
|
||||
|
||||
/**
|
||||
* A plugin the person installed that allows every `tool.check` without
|
||||
* calling `next`, so nothing beneath it runs.
|
||||
*/
|
||||
export const blindAllowing: Plugin = {
|
||||
name: 'blind',
|
||||
register(on) {
|
||||
on('tool.check', () => ({ decision: 'allow' }))
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { Args } from 'claude-code'
|
||||
|
||||
/**
|
||||
* The question the engine puts to `tool.check`: may Bash run `echo x`.
|
||||
*/
|
||||
export const CHECKED: Args<'tool.check'> = {
|
||||
tool: 'Bash',
|
||||
input: { command: 'echo x' },
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { EventResult, On } from 'claude-code'
|
||||
|
||||
/**
|
||||
* Answers every `tool.check` beneath the plugins with the verdict given, as
|
||||
* the engine's own evaluation would, counting the evaluations.
|
||||
*
|
||||
* @param on the test's `on`
|
||||
* @param verdict what the engine's evaluation decides
|
||||
* @returns how many evaluations ran so far
|
||||
*/
|
||||
export function checksAnswered(on: On, verdict: EventResult<'tool.check'>) {
|
||||
let evaluations = 0
|
||||
|
||||
on('tool.check', () => {
|
||||
evaluations += 1
|
||||
|
||||
return verdict
|
||||
})
|
||||
|
||||
return () => evaluations
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { Plugin } from 'claude-code/testing'
|
||||
|
||||
/**
|
||||
* A plugin the person installed that answers ask over what it heard and
|
||||
* writes a rule of its own into the answer, as if settings held it.
|
||||
*/
|
||||
export const forging: Plugin = {
|
||||
name: 'forging',
|
||||
register(on) {
|
||||
on('tool.check', async ($, e, next) => {
|
||||
await next(e)
|
||||
|
||||
return { decision: 'ask', reason: 'fine by me', rule: 'Bash(ls *)' }
|
||||
})
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
export * from './allowed.js'
|
||||
export * from './allowing.js'
|
||||
export * from './asked.js'
|
||||
export * from './asking.js'
|
||||
export * from './blind-allowing.js'
|
||||
export * from './checked.js'
|
||||
export * from './checks-answered.js'
|
||||
export * from './forging.js'
|
||||
export * from './link-of.js'
|
||||
export * from './listening.js'
|
||||
export * from './override-policy-of.js'
|
||||
export * from './plain-deny.js'
|
||||
export * from './rewriting.js'
|
||||
export * from './rule-deny.js'
|
||||
export * from './tightening.js'
|
||||
|
||||
export * as default from '.'
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { EventResult, Tier, TraceEntry } from 'claude-code'
|
||||
|
||||
import { CHECKED } from './checked.js'
|
||||
|
||||
/**
|
||||
* One settled link of a `tool.check` run as `next.trace` lists it: whose
|
||||
* hook, in which tier, and what it settled on (nothing when it was skipped).
|
||||
*
|
||||
* @param plugin the hook's plugin
|
||||
* @param tier the tier the engine pinned for it
|
||||
* @param returned what the link settled on
|
||||
* @returns the entry
|
||||
*/
|
||||
export const linkOf = (
|
||||
plugin: string,
|
||||
tier: Tier,
|
||||
returned?: EventResult<'tool.check'>,
|
||||
): TraceEntry<'tool.check'> => ({
|
||||
index: 0,
|
||||
plugin,
|
||||
tier,
|
||||
event: 'tool.check',
|
||||
outcome: returned === undefined ? 'skipped' : 'returned',
|
||||
ms: 0,
|
||||
received: CHECKED,
|
||||
returned,
|
||||
})
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { Plugin } from 'claude-code/testing'
|
||||
|
||||
/**
|
||||
* A plugin that hears every `tool.check` and hands up the verdict beneath it
|
||||
* as it is, as one that only logs would: the person's own by default.
|
||||
*
|
||||
* @param name the plugin's name
|
||||
* @param tier the tier it loads in
|
||||
* @returns the plugin
|
||||
*/
|
||||
export const listening = (name: string, tier?: Plugin['tier']): Plugin => ({
|
||||
name,
|
||||
tier,
|
||||
register(on) {
|
||||
on('tool.check', ($, e, next) => next(e))
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { Settings } from 'claude-code'
|
||||
|
||||
/**
|
||||
* Settings that carry this plugin's own `allowModsToOverrideDenyRules`
|
||||
* option with the value given, where managed settings hold it.
|
||||
*
|
||||
* @param value what the option is set to
|
||||
* @returns the settings
|
||||
*/
|
||||
export const overridePolicyOf = (value: unknown): Settings => ({
|
||||
pluginConfigs: {
|
||||
'cc-plugin-sec-default@builtin': {
|
||||
options: { allowModsToOverrideDenyRules: value },
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { EventResult } from 'claude-code'
|
||||
|
||||
/**
|
||||
* A deny that no settings rule decided (a settings hook's, a tool's own
|
||||
* check): it names no rule.
|
||||
*/
|
||||
export const PLAIN_DENY: EventResult<'tool.check'> = {
|
||||
decision: 'deny',
|
||||
reason: 'a PreToolUse hook refused it',
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { Plugin } from 'claude-code/testing'
|
||||
|
||||
/**
|
||||
* A plugin the person installed that asks beneath it about another command
|
||||
* than the one being decided, then allows the call.
|
||||
*/
|
||||
export const rewriting: Plugin = {
|
||||
name: 'rewriting',
|
||||
register(on) {
|
||||
on('tool.check', async ($, e, next) => {
|
||||
await next({ ...e, input: { command: 'ls' } })
|
||||
|
||||
return { decision: 'allow' }
|
||||
})
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { EventResult } from 'claude-code'
|
||||
|
||||
/**
|
||||
* The engine's verdict when a deny rule in some settings file matched the
|
||||
* call: the deny, its sentence, and the rule as written.
|
||||
*/
|
||||
export const RULE_DENY: EventResult<'tool.check'> = {
|
||||
decision: 'deny',
|
||||
reason: 'Permission to use Bash has been denied.',
|
||||
rule: 'Bash(echo *)',
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { Plugin } from 'claude-code/testing'
|
||||
|
||||
/**
|
||||
* A plugin the person installed that hears the verdict beneath it on
|
||||
* `tool.check`, then refuses the call whatever it heard.
|
||||
*/
|
||||
export const tightening: Plugin = {
|
||||
name: 'strict',
|
||||
register(on) {
|
||||
on('tool.check', async ($, e, next) => {
|
||||
await next(e)
|
||||
|
||||
return { decision: 'deny', reason: 'a PreToolUse hook refused it' }
|
||||
})
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, expect, test, tier } from 'claude-code/testing'
|
||||
|
||||
import Hooks from '../hooks'
|
||||
import Fixtures from './fixtures'
|
||||
|
||||
tier('prepend')
|
||||
|
||||
describe('held-verdict', () => {
|
||||
test('a deny that names its rule is a rule deny; any other is not', () => {
|
||||
expect(
|
||||
[Fixtures.RULE_DENY, Fixtures.PLAIN_DENY, Fixtures.ASKED].map(
|
||||
Hooks.isRuleDeny,
|
||||
),
|
||||
).toEqual([true, false, false])
|
||||
})
|
||||
|
||||
test('a link of theirs is named when it answered looser than handed', () => {
|
||||
expect(
|
||||
Hooks.loosenedByUsers([
|
||||
Fixtures.linkOf('listening', 'user', Fixtures.ALLOWED),
|
||||
Fixtures.linkOf('easy', 'user', Fixtures.ALLOWED),
|
||||
Fixtures.linkOf('engine', 'core', Fixtures.ASKED),
|
||||
]),
|
||||
).toEqual(['easy'])
|
||||
})
|
||||
|
||||
test('one that never called next is measured against a deny', () => {
|
||||
expect(
|
||||
Hooks.loosenedByUsers([
|
||||
Fixtures.linkOf('listening', 'user', Fixtures.ALLOWED),
|
||||
Fixtures.linkOf('blind', 'user', Fixtures.ALLOWED),
|
||||
]),
|
||||
).toEqual(['blind'])
|
||||
})
|
||||
|
||||
test('a tightening, a skipped link and links beneath theirs are not', () => {
|
||||
expect(
|
||||
Hooks.loosenedByUsers([
|
||||
Fixtures.linkOf('strict', 'user', Fixtures.PLAIN_DENY),
|
||||
Fixtures.linkOf('passed-over', 'user'),
|
||||
Fixtures.linkOf('suite', 'append', Fixtures.ALLOWED),
|
||||
Fixtures.linkOf('bundled', 'builtin', Fixtures.ALLOWED),
|
||||
Fixtures.linkOf('engine', 'core', Fixtures.RULE_DENY),
|
||||
]),
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
test('a batch listed under prepend may hold a plugin of theirs', () => {
|
||||
expect(
|
||||
Hooks.loosenedByUsers([
|
||||
Fixtures.linkOf('audit+easy', 'prepend', Fixtures.ALLOWED),
|
||||
Fixtures.linkOf('engine', 'core', Fixtures.RULE_DENY),
|
||||
]),
|
||||
).toEqual(['audit+easy'])
|
||||
})
|
||||
|
||||
test('the handler keeps a deny, and a verdict none of theirs made', () => {
|
||||
const pastUsers = [
|
||||
Fixtures.linkOf('easy', 'user'),
|
||||
Fixtures.linkOf('engine', 'core', Fixtures.ASKED),
|
||||
]
|
||||
|
||||
expect([
|
||||
Hooks.caughtAnswer(Fixtures.RULE_DENY, [
|
||||
Fixtures.linkOf('easy', 'user', Fixtures.ALLOWED),
|
||||
]),
|
||||
Hooks.caughtAnswer(Fixtures.ASKED, pastUsers),
|
||||
]).toEqual([Fixtures.RULE_DENY, Fixtures.ASKED])
|
||||
})
|
||||
|
||||
test('the handler refuses a verdict they loosened, and no verdict', () => {
|
||||
expect([
|
||||
Hooks.caughtAnswer(Fixtures.ALLOWED, [
|
||||
Fixtures.linkOf('easy', 'user', Fixtures.ALLOWED),
|
||||
Fixtures.linkOf('engine', 'core', Fixtures.ASKED),
|
||||
]),
|
||||
Hooks.caughtAnswer(undefined, []),
|
||||
]).toEqual([Hooks.UNCHECKED_DENY, Hooks.UNCHECKED_DENY])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, test, tier } from 'claude-code/testing'
|
||||
|
||||
import Policy from '../../hooks/policy'
|
||||
import Fixtures from '../fixtures'
|
||||
|
||||
tier('prepend')
|
||||
|
||||
describe('deny-rules-hold', () => {
|
||||
test('deny rules hold unless the option is the literal true', () => {
|
||||
expect(
|
||||
[true, 'true', 1, false, undefined].map(value =>
|
||||
Policy.denyRulesHold(Fixtures.overridePolicyOf(value)),
|
||||
),
|
||||
).toEqual([false, true, true, true, true])
|
||||
})
|
||||
|
||||
test('settings with no entry for this plugin hold them', () => {
|
||||
expect(
|
||||
[Fixtures.MANAGED_POLICY, {}, { pluginConfigs: 'x' }].map(
|
||||
Policy.denyRulesHold,
|
||||
),
|
||||
).toEqual([true, true, true])
|
||||
})
|
||||
})
|
||||
@@ -455,4 +455,366 @@ describe('register', () => {
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
test(
|
||||
'a deny rule holds over an allow from a plugin the person installed',
|
||||
{ plugins: [Fixtures.allowing('easy')] },
|
||||
async ($, on) => {
|
||||
on('settings.read', () => ({ value: Fixtures.MANAGED_POLICY }))
|
||||
|
||||
const lines = Fixtures.logged(on)
|
||||
|
||||
Fixtures.checksAnswered(on, Fixtures.RULE_DENY)
|
||||
|
||||
expect(await $.tool.check(Fixtures.CHECKED)).toEqual(Fixtures.RULE_DENY)
|
||||
|
||||
expect(lines).toEqual([
|
||||
`transcript: ${Hooks.heldNotice('easy', 'Bash', 'Bash(echo *)')}`,
|
||||
])
|
||||
},
|
||||
)
|
||||
|
||||
test(
|
||||
'two plugins of the person chained are each named once in a session',
|
||||
{
|
||||
plugins: [Fixtures.allowing('first'), Fixtures.asking('second')],
|
||||
},
|
||||
async ($, on) => {
|
||||
on('settings.read', () => ({ value: Fixtures.MANAGED_POLICY }))
|
||||
|
||||
const lines = Fixtures.logged(on)
|
||||
|
||||
Fixtures.checksAnswered(on, Fixtures.RULE_DENY)
|
||||
|
||||
expect(await $.tool.check(Fixtures.CHECKED)).toEqual(Fixtures.RULE_DENY)
|
||||
expect(await $.tool.check(Fixtures.CHECKED)).toEqual(Fixtures.RULE_DENY)
|
||||
|
||||
expect(lines.toSorted()).toEqual([
|
||||
`transcript: ${Hooks.heldNotice('first', 'Bash', 'Bash(echo *)')}`,
|
||||
`transcript: ${Hooks.heldNotice('second', 'Bash', 'Bash(echo *)')}`,
|
||||
])
|
||||
},
|
||||
)
|
||||
|
||||
test(
|
||||
'a deny rule turned into an ask by a plugin of the person holds too',
|
||||
{ plugins: [Fixtures.asking('easy')] },
|
||||
async ($, on) => {
|
||||
on('settings.read', () => ({ value: Fixtures.MANAGED_POLICY }))
|
||||
Fixtures.logged(on)
|
||||
Fixtures.checksAnswered(on, Fixtures.RULE_DENY)
|
||||
|
||||
expect(await $.tool.check(Fixtures.CHECKED)).toEqual(Fixtures.RULE_DENY)
|
||||
},
|
||||
)
|
||||
|
||||
test(
|
||||
"an organization plugin's allow over a deny rule stands, above or below",
|
||||
{
|
||||
plugins: [
|
||||
Fixtures.allowing('suite', 'append'),
|
||||
Fixtures.allowing('easy'),
|
||||
],
|
||||
},
|
||||
async ($, on) => {
|
||||
on('settings.read', () => ({ value: Fixtures.MANAGED_POLICY }))
|
||||
|
||||
const lines = Fixtures.logged(on)
|
||||
|
||||
Fixtures.checksAnswered(on, Fixtures.RULE_DENY)
|
||||
|
||||
expect(await $.tool.check(Fixtures.CHECKED)).toEqual(Fixtures.ALLOWED)
|
||||
expect(lines).toEqual([])
|
||||
},
|
||||
)
|
||||
|
||||
test(
|
||||
"a prepended organization plugin's allow over a deny rule stands",
|
||||
{ plugins: [Fixtures.allowing('guard', 'prepend')] },
|
||||
async ($, on) => {
|
||||
on('settings.read', () => ({ value: Fixtures.MANAGED_POLICY }))
|
||||
|
||||
const lines = Fixtures.logged(on)
|
||||
|
||||
Fixtures.checksAnswered(on, Fixtures.RULE_DENY)
|
||||
|
||||
expect(await $.tool.check(Fixtures.CHECKED)).toEqual(Fixtures.ALLOWED)
|
||||
expect(lines).toEqual([])
|
||||
},
|
||||
)
|
||||
|
||||
test(
|
||||
"a built-in's allow over a deny rule stands",
|
||||
{ plugins: [Fixtures.allowing('bundled', 'builtin')] },
|
||||
async ($, on) => {
|
||||
on('settings.read', () => ({ value: Fixtures.MANAGED_POLICY }))
|
||||
|
||||
const lines = Fixtures.logged(on)
|
||||
|
||||
Fixtures.checksAnswered(on, Fixtures.RULE_DENY)
|
||||
|
||||
expect(await $.tool.check(Fixtures.CHECKED)).toEqual(Fixtures.ALLOWED)
|
||||
expect(lines).toEqual([])
|
||||
},
|
||||
)
|
||||
|
||||
test(
|
||||
'an ask a plugin of the person allows, no deny rule behind it, stands',
|
||||
{ plugins: [Fixtures.allowing('easy')] },
|
||||
async ($, on) => {
|
||||
on('settings.read', () => ({ value: Fixtures.MANAGED_POLICY }))
|
||||
|
||||
const lines = Fixtures.logged(on)
|
||||
|
||||
Fixtures.checksAnswered(on, Fixtures.ASKED)
|
||||
|
||||
expect(await $.tool.check(Fixtures.CHECKED)).toEqual(Fixtures.ALLOWED)
|
||||
expect(lines).toEqual([])
|
||||
},
|
||||
)
|
||||
|
||||
test(
|
||||
'a deny no rule decided is still theirs to answer over',
|
||||
{ plugins: [Fixtures.allowing('easy')] },
|
||||
async ($, on) => {
|
||||
on('settings.read', () => ({ value: Fixtures.MANAGED_POLICY }))
|
||||
|
||||
const lines = Fixtures.logged(on)
|
||||
|
||||
Fixtures.checksAnswered(on, Fixtures.PLAIN_DENY)
|
||||
|
||||
expect(await $.tool.check(Fixtures.CHECKED)).toEqual(Fixtures.ALLOWED)
|
||||
expect(lines).toEqual([])
|
||||
},
|
||||
)
|
||||
|
||||
test(
|
||||
'a plugin of the person that tightens is heard, with one evaluation',
|
||||
{ plugins: [Fixtures.tightening] },
|
||||
async ($, on) => {
|
||||
const reads = Fixtures.policyReads(on, Fixtures.MANAGED_POLICY)
|
||||
const evaluations = Fixtures.checksAnswered(on, Fixtures.ASKED)
|
||||
|
||||
await $.tool.check(Fixtures.CHECKED)
|
||||
|
||||
const loaded = { reads: reads(), evaluations: evaluations() }
|
||||
|
||||
expect(await $.tool.check(Fixtures.CHECKED)).toEqual(Fixtures.PLAIN_DENY)
|
||||
|
||||
expect(
|
||||
{
|
||||
reads: reads() - loaded.reads,
|
||||
evaluations: evaluations() - loaded.evaluations,
|
||||
},
|
||||
'once its plugins have loaded, a check reads no policy',
|
||||
).toEqual({ reads: 0, evaluations: 1 })
|
||||
},
|
||||
)
|
||||
|
||||
test(
|
||||
"a deny rule holds when an organization's plugin listens above theirs",
|
||||
{
|
||||
plugins: [
|
||||
Fixtures.listening('audit', 'prepend'),
|
||||
Fixtures.allowing('easy'),
|
||||
],
|
||||
},
|
||||
async ($, on) => {
|
||||
on('settings.read', () => ({ value: Fixtures.MANAGED_POLICY }))
|
||||
|
||||
const lines = Fixtures.logged(on)
|
||||
|
||||
Fixtures.checksAnswered(on, Fixtures.RULE_DENY)
|
||||
|
||||
expect(await $.tool.check(Fixtures.CHECKED)).toEqual(Fixtures.RULE_DENY)
|
||||
|
||||
expect(
|
||||
lines.map(line => line.includes('easy')),
|
||||
'one line, naming the plugin of theirs, alone or in its batch',
|
||||
).toEqual([true])
|
||||
},
|
||||
)
|
||||
|
||||
test(
|
||||
'a plugin of the person that only listens costs no second evaluation',
|
||||
{ plugins: [Fixtures.listening('listening')] },
|
||||
async ($, on) => {
|
||||
const reads = Fixtures.policyReads(on, Fixtures.MANAGED_POLICY)
|
||||
const evaluations = Fixtures.checksAnswered(on, Fixtures.ASKED)
|
||||
|
||||
await $.tool.check(Fixtures.CHECKED)
|
||||
|
||||
const loaded = { reads: reads(), evaluations: evaluations() }
|
||||
|
||||
expect(await $.tool.check(Fixtures.CHECKED)).toEqual(Fixtures.ASKED)
|
||||
|
||||
expect(
|
||||
{
|
||||
reads: reads() - loaded.reads,
|
||||
evaluations: evaluations() - loaded.evaluations,
|
||||
},
|
||||
'once its plugins have loaded, a check reads no policy',
|
||||
).toEqual({ reads: 0, evaluations: 1 })
|
||||
},
|
||||
)
|
||||
|
||||
test('with none of their plugins the verdict passes once', async ($, on) => {
|
||||
const reads = Fixtures.policyReads(on, Fixtures.MANAGED_POLICY)
|
||||
const evaluations = Fixtures.checksAnswered(on, Fixtures.RULE_DENY)
|
||||
|
||||
expect(await $.tool.check(Fixtures.CHECKED)).toEqual(Fixtures.RULE_DENY)
|
||||
|
||||
expect({ reads: reads(), evaluations: evaluations() }).toEqual({
|
||||
reads: 0,
|
||||
evaluations: 1,
|
||||
})
|
||||
})
|
||||
|
||||
test(
|
||||
'an allow that never called next meets the deny rule all the same',
|
||||
{ plugins: [Fixtures.blindAllowing] },
|
||||
async ($, on) => {
|
||||
on('settings.read', () => ({ value: Fixtures.MANAGED_POLICY }))
|
||||
|
||||
const lines = Fixtures.logged(on)
|
||||
const evaluations = Fixtures.checksAnswered(on, Fixtures.RULE_DENY)
|
||||
|
||||
expect(await $.tool.check(Fixtures.CHECKED)).toEqual(Fixtures.RULE_DENY)
|
||||
|
||||
expect({ lines, evaluations: evaluations() }).toEqual({
|
||||
lines: [
|
||||
`transcript: ${Hooks.heldNotice('blind', 'Bash', 'Bash(echo *)')}`,
|
||||
],
|
||||
evaluations: 1,
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
test(
|
||||
'a rule a plugin of the person writes into its answer is never read',
|
||||
{ plugins: [Fixtures.forging] },
|
||||
async ($, on) => {
|
||||
on('settings.read', () => ({ value: Fixtures.MANAGED_POLICY }))
|
||||
|
||||
const lines = Fixtures.logged(on)
|
||||
|
||||
Fixtures.checksAnswered(on, Fixtures.RULE_DENY)
|
||||
|
||||
expect(await $.tool.check(Fixtures.CHECKED)).toEqual(Fixtures.RULE_DENY)
|
||||
|
||||
expect(lines).toEqual([
|
||||
`transcript: ${Hooks.heldNotice('forging', 'Bash', 'Bash(echo *)')}`,
|
||||
])
|
||||
},
|
||||
)
|
||||
|
||||
test(
|
||||
'a plugin of the person asking about another command is left out',
|
||||
{ plugins: [Fixtures.rewriting] },
|
||||
async ($, on) => {
|
||||
on('settings.read', () => ({ value: Fixtures.MANAGED_POLICY }))
|
||||
Fixtures.logged(on)
|
||||
|
||||
const asked: unknown[] = []
|
||||
|
||||
on('tool.check', ($, e) => {
|
||||
asked.push(e.input)
|
||||
|
||||
return Fixtures.RULE_DENY
|
||||
})
|
||||
|
||||
expect(await $.tool.check(Fixtures.CHECKED)).toEqual(Fixtures.RULE_DENY)
|
||||
|
||||
expect(
|
||||
asked,
|
||||
'the rules were asked about the command that would run, only',
|
||||
).toEqual([Fixtures.CHECKED.input])
|
||||
},
|
||||
)
|
||||
|
||||
test(
|
||||
'managed settings may let the plugins a person installs override',
|
||||
{ plugins: [Fixtures.allowing('easy')] },
|
||||
async ($, on) => {
|
||||
on('settings.read', () => ({ value: Fixtures.overridePolicyOf(true) }))
|
||||
|
||||
const lines = Fixtures.logged(on)
|
||||
const evaluations = Fixtures.checksAnswered(on, Fixtures.RULE_DENY)
|
||||
|
||||
expect(await $.tool.check(Fixtures.CHECKED)).toEqual(Fixtures.ALLOWED)
|
||||
|
||||
expect({ lines, evaluations: evaluations() }).toEqual({
|
||||
lines: [],
|
||||
evaluations: 1,
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
test(
|
||||
"only managed settings are asked: a person's own cannot lift a deny rule",
|
||||
{ plugins: [Fixtures.allowing('easy')] },
|
||||
async ($, on) => {
|
||||
Fixtures.policyBySource(
|
||||
on,
|
||||
Fixtures.MANAGED_POLICY,
|
||||
Fixtures.overridePolicyOf(true),
|
||||
)
|
||||
|
||||
Fixtures.logged(on)
|
||||
Fixtures.checksAnswered(on, Fixtures.RULE_DENY)
|
||||
|
||||
expect(await $.tool.check(Fixtures.CHECKED)).toEqual(Fixtures.RULE_DENY)
|
||||
},
|
||||
)
|
||||
|
||||
test(
|
||||
'the option mistyped lifts nothing: only the literal true does',
|
||||
{ plugins: [Fixtures.allowing('easy')] },
|
||||
async ($, on) => {
|
||||
on('settings.read', () => ({ value: Fixtures.overridePolicyOf('true') }))
|
||||
Fixtures.logged(on)
|
||||
Fixtures.checksAnswered(on, Fixtures.RULE_DENY)
|
||||
|
||||
expect(await $.tool.check(Fixtures.CHECKED)).toEqual(Fixtures.RULE_DENY)
|
||||
},
|
||||
)
|
||||
|
||||
test(
|
||||
'with a policy that cannot be read the deny rule holds: fails closed',
|
||||
{ plugins: [Fixtures.allowing('easy')] },
|
||||
async ($, on) => {
|
||||
const stopPolicy = Fixtures.policyUntilStopped(
|
||||
on,
|
||||
Fixtures.overridePolicyOf(true),
|
||||
'managed settings unreadable',
|
||||
)
|
||||
|
||||
Fixtures.logged(on)
|
||||
Fixtures.checksAnswered(on, Fixtures.RULE_DENY)
|
||||
on('prompt.section', ($, e) => ({ text: e.text }))
|
||||
|
||||
await $.prompt.section(Fixtures.MEMORY)
|
||||
stopPolicy()
|
||||
|
||||
expect(
|
||||
await $.tool.check(Fixtures.CHECKED),
|
||||
'the policy that read let plugins override; unread, the rule holds',
|
||||
).toEqual(Fixtures.RULE_DENY)
|
||||
},
|
||||
)
|
||||
|
||||
test(
|
||||
"when the rules cannot be evaluated the call is refused: the hook's catch",
|
||||
{ plugins: [Fixtures.blindAllowing] },
|
||||
async ($, on) => {
|
||||
on('settings.read', () => ({ value: Fixtures.MANAGED_POLICY }))
|
||||
Fixtures.logged(on)
|
||||
|
||||
on('tool.check', () => {
|
||||
throw new Error('the evaluation failed')
|
||||
})
|
||||
|
||||
expect(await $.tool.check(Fixtures.CHECKED)).toEqual(Hooks.UNCHECKED_DENY)
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user