mirror of
https://github.com/anthropics/claude-code.git
synced 2026-10-02 05:25:05 +08:00
sec-default: a user-tier link counts as loosening only when it answers looser than it was handed
This commit is contained in:
@@ -30,7 +30,7 @@ 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 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. |
|
||||
| `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. |
|
||||
| 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`. |
|
||||
|
||||
## What it hooks
|
||||
@@ -58,9 +58,11 @@ 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
|
||||
user-tier link answered `allow` or `ask` (read off `next.trace`, whose
|
||||
tiers the engine pins), the answer passes: nothing of the person's loosened
|
||||
anything.
|
||||
user-tier link answered more permissively than the verdict handed up to it
|
||||
(read off `next.trace`, whose tiers the engine pins; a link that never
|
||||
called `next` is measured against a deny), the answer passes: nothing of
|
||||
the person's loosened anything, and a plugin that only listens costs
|
||||
nothing more.
|
||||
- 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
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export * from './is-rule-deny.js'
|
||||
export * from './loosened-by-users.js'
|
||||
export * from './ranking'
|
||||
export * from './types'
|
||||
export * from './unchecked-deny.js'
|
||||
|
||||
|
||||
@@ -1,23 +1,24 @@
|
||||
import type { TraceEntry } from 'claude-code'
|
||||
|
||||
import Ranking from './ranking'
|
||||
|
||||
/**
|
||||
* The plugins a person installed that answered `allow` or `ask` in one run
|
||||
* of `tool.check`, by name, nearest the caller first.
|
||||
* The plugins a person installed that loosened the verdict in one run of
|
||||
* `tool.check`: each answered more permissively than it was handed.
|
||||
*
|
||||
* Read off `next.trace`: each link's `tier` is pinned by the engine, and a
|
||||
* link that was skipped or rejected settled on nothing.
|
||||
* Read off `next.trace`, whose `tier` the engine pins. A link that passed a
|
||||
* verdict on, tightened it, or was skipped is not named.
|
||||
*
|
||||
* @param trace what settled beneath the hook on its latest `next` call
|
||||
* @returns the names; none when no user-tier link answered looser than deny
|
||||
* @returns their names, nearest the caller first; none when none loosened
|
||||
*/
|
||||
export const loosenedByUsers = (
|
||||
trace: readonly TraceEntry<'tool.check'>[],
|
||||
): readonly string[] =>
|
||||
trace
|
||||
.filter(
|
||||
link =>
|
||||
(link, at) =>
|
||||
link.tier === 'user' &&
|
||||
link.returned !== undefined &&
|
||||
link.returned.decision !== 'deny',
|
||||
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,5 @@
|
||||
export * from './handed-to.js'
|
||||
export * from './is-looser.js'
|
||||
export * from './leniency'
|
||||
|
||||
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 })
|
||||
@@ -7,6 +7,7 @@ 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'
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { Plugin } from 'claude-code/testing'
|
||||
|
||||
/**
|
||||
* A plugin the person installed that hears every `tool.check` and hands up
|
||||
* the verdict beneath it as it is, as one that only logs would.
|
||||
*/
|
||||
export const listening: Plugin = {
|
||||
name: 'listening',
|
||||
register(on) {
|
||||
on('tool.check', ($, e, next) => next(e))
|
||||
},
|
||||
}
|
||||
@@ -14,18 +14,35 @@ describe('held-verdict', () => {
|
||||
).toEqual([true, false, false])
|
||||
})
|
||||
|
||||
test("only the person's links that answered allow or ask are named", () => {
|
||||
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 an organization link are not', () => {
|
||||
expect(
|
||||
Hooks.loosenedByUsers([
|
||||
Fixtures.linkOf('guard', 'prepend', Fixtures.ALLOWED),
|
||||
Fixtures.linkOf('easy', 'user', Fixtures.ALLOWED),
|
||||
Fixtures.linkOf('strict', 'user', Fixtures.PLAIN_DENY),
|
||||
Fixtures.linkOf('passed-over', 'user'),
|
||||
Fixtures.linkOf('asking', 'user', Fixtures.ASKED),
|
||||
Fixtures.linkOf('bundled', 'builtin', Fixtures.ALLOWED),
|
||||
Fixtures.linkOf('engine', 'core', Fixtures.RULE_DENY),
|
||||
]),
|
||||
).toEqual(['easy', 'asking'])
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
test('the handler keeps a deny, and a verdict none of theirs made', () => {
|
||||
|
||||
@@ -455,6 +455,22 @@ describe('register', () => {
|
||||
},
|
||||
)
|
||||
|
||||
test(
|
||||
'a plugin of the person that only listens costs no second evaluation',
|
||||
{ plugins: [Fixtures.listening] },
|
||||
async ($, on) => {
|
||||
const reads = Fixtures.policyReads(on, Fixtures.MANAGED_POLICY)
|
||||
const evaluations = Fixtures.checksAnswered(on, Fixtures.ASKED)
|
||||
|
||||
expect(await $.tool.check(Fixtures.CHECKED)).toEqual(Fixtures.ASKED)
|
||||
|
||||
expect({ reads: reads(), evaluations: evaluations() }).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)
|
||||
|
||||
Reference in New Issue
Block a user