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 user-tier link counts as loosening only when it answers looser than it was handedBrowse files
poteat authored 25 days ago parent 9dfc532 · commit 0b3a956d8664
12 changed files+116 -16compared with 9dfc532
Mmods/sec-default/README.md
Mmods/sec-default/hooks/held-verdict/verdicts/index.ts
Mmods/sec-default/hooks/held-verdict/verdicts/loosened-by-users.ts
Amods/sec-default/hooks/held-verdict/verdicts/ranking/handed-to.ts
Amods/sec-default/hooks/held-verdict/verdicts/ranking/index.ts
Amods/sec-default/hooks/held-verdict/verdicts/ranking/is-looser.ts
Amods/sec-default/hooks/held-verdict/verdicts/ranking/leniency/index.ts
Amods/sec-default/hooks/held-verdict/verdicts/ranking/leniency/leniency.ts
Mmods/sec-default/tests/fixtures/tool-check/index.ts
Amods/sec-default/tests/fixtures/tool-check/listening.ts
Mmods/sec-default/tests/held-verdict.test.ts
Mmods/sec-default/tests/register.test.ts
@@ -30,7 +30,7 @@
3030 | `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. |
3131 | `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. |
3232 | `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. |
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. |
33+| `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. |
3434 | 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`. |
3535
3636 ## What it hooks
@@ -58,9 +58,11 @@
5858 seated it does not:
5959
6060 - 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.
61+ user-tier link answered more permissively than the verdict handed up to it
62+ (read off `next.trace`, whose tiers the engine pins; a link that never
63+ called `next` is measured against a deny), the answer passes: nothing of
64+ the person's loosened anything, and a plugin that only listens costs
65+ nothing more.
6466 - Otherwise it runs the dispatch once more with the user tier left out
6567 (`next.to(e, "append")`). That verdict never passed through a person's
6668 plugin, so neither the decision nor the rule it names can have been
@@ -1,5 +1,6 @@
11 export * from './is-rule-deny.js'
22 export * from './loosened-by-users.js'
3+export * from './ranking'
34 export * from './types'
45 export * from './unchecked-deny.js'
56
@@ -1,23 +1,24 @@
11 import type { TraceEntry } from 'claude-code'
22
3+import Ranking from './ranking'
4+
35 /**
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+ * The plugins a person installed that loosened the verdict in one run of
7+ * `tool.check`: each answered more permissively than it was handed.
68 *
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+ * Read off `next.trace`, whose `tier` the engine pins. A link that passed a
10+ * verdict on, tightened it, or was skipped is not named.
911 *
1012 * @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
13+ * @returns their names, nearest the caller first; none when none loosened
1214 */
1315 export const loosenedByUsers = (
1416 trace: readonly TraceEntry<'tool.check'>[],
1517 ): readonly string[] =>
1618 trace
1719 .filter(
18- link =>
20+ (link, at) =>
1921 link.tier === 'user' &&
20- link.returned !== undefined &&
21- link.returned.decision !== 'deny',
22+ Ranking.isLooser(link.returned, Ranking.handedTo(trace, at)),
2223 )
2324 .map(link => link.plugin)
@@ -1,0 +1,17 @@
1+import type { TraceEntry } from 'claude-code'
2+
3+/**
4+ * The verdict handed up to one link of a run: what the nearest link beneath
5+ * it that settled on anything settled on.
6+ *
7+ * Undefined for a link that answered without calling `next`: the trace ends
8+ * short of the engine there, and nothing beneath it ran.
9+ *
10+ * @param trace a run as `next.trace` lists it, nearest the caller first
11+ * @param at the link's place in that list
12+ * @returns the verdict, or undefined when nothing settled beneath the link
13+ */
14+export const handedTo = (
15+ trace: readonly TraceEntry<'tool.check'>[],
16+ at: number,
17+) => trace.slice(at + 1).find(link => link.returned !== undefined)?.returned
@@ -1,0 +1,5 @@
1+export * from './handed-to.js'
2+export * from './is-looser.js'
3+export * from './leniency'
4+
5+export * as default from '.'
@@ -1,0 +1,20 @@
1+import type { EventResult } from 'claude-code'
2+
3+import { LENIENCY } from './leniency'
4+
5+/**
6+ * Whether a link's own verdict is more permissive than the one handed up to
7+ * it; with nothing handed up (it never called `next`), than a deny.
8+ *
9+ * A link that settled on nothing (skipped, rejected) loosened nothing.
10+ *
11+ * @param own what the link settled on
12+ * @param handed what settled beneath it and was handed up, if anything did
13+ * @returns true when the link loosened the verdict
14+ */
15+export const isLooser = (
16+ own: EventResult<'tool.check'> | undefined,
17+ handed: EventResult<'tool.check'> | undefined,
18+) =>
19+ own !== undefined &&
20+ LENIENCY[own.decision] > LENIENCY[handed?.decision ?? 'deny']
@@ -1,0 +1,3 @@
1+export * from './leniency.js'
2+
3+export * as default from '.'
@@ -1,0 +1,5 @@
1+/**
2+ * How permissive each `tool.check` verdict is, the refusal lowest: a link
3+ * loosened a verdict when its own ranks above the one handed up to it.
4+ */
5+export const LENIENCY = Object.freeze({ deny: 0, ask: 1, allow: 2 })
@@ -7,6 +7,7 @@
77 export * from './checks-answered.js'
88 export * from './forging.js'
99 export * from './link-of.js'
10+export * from './listening.js'
1011 export * from './override-policy-of.js'
1112 export * from './plain-deny.js'
1213 export * from './rewriting.js'
@@ -1,0 +1,12 @@
1+import type { Plugin } from 'claude-code/testing'
2+
3+/**
4+ * A plugin the person installed that hears every `tool.check` and hands up
5+ * the verdict beneath it as it is, as one that only logs would.
6+ */
7+export const listening: Plugin = {
8+ name: 'listening',
9+ register(on) {
10+ on('tool.check', ($, e, next) => next(e))
11+ },
12+}
@@ -14,18 +14,35 @@
1414 ).toEqual([true, false, false])
1515 })
1616
17- test("only the person's links that answered allow or ask are named", () => {
17+ test('a link of theirs is named when it answered looser than handed', () => {
1818 expect(
1919 Hooks.loosenedByUsers([
20- Fixtures.linkOf('guard', 'prepend', Fixtures.ALLOWED),
20+ Fixtures.linkOf('listening', 'user', Fixtures.ALLOWED),
2121 Fixtures.linkOf('easy', 'user', Fixtures.ALLOWED),
22+ Fixtures.linkOf('engine', 'core', Fixtures.ASKED),
23+ ]),
24+ ).toEqual(['easy'])
25+ })
26+
27+ test('one that never called next is measured against a deny', () => {
28+ expect(
29+ Hooks.loosenedByUsers([
30+ Fixtures.linkOf('listening', 'user', Fixtures.ALLOWED),
31+ Fixtures.linkOf('blind', 'user', Fixtures.ALLOWED),
32+ ]),
33+ ).toEqual(['blind'])
34+ })
35+
36+ test('a tightening, a skipped link and an organization link are not', () => {
37+ expect(
38+ Hooks.loosenedByUsers([
39+ Fixtures.linkOf('guard', 'prepend', Fixtures.ALLOWED),
2240 Fixtures.linkOf('strict', 'user', Fixtures.PLAIN_DENY),
2341 Fixtures.linkOf('passed-over', 'user'),
24- Fixtures.linkOf('asking', 'user', Fixtures.ASKED),
2542 Fixtures.linkOf('bundled', 'builtin', Fixtures.ALLOWED),
2643 Fixtures.linkOf('engine', 'core', Fixtures.RULE_DENY),
2744 ]),
28- ).toEqual(['easy', 'asking'])
45+ ).toEqual([])
2946 })
3047
3148 test('the handler keeps a deny, and a verdict none of theirs made', () => {
@@ -455,6 +455,22 @@
455455 },
456456 )
457457
458+ test(
459+ 'a plugin of the person that only listens costs no second evaluation',
460+ { plugins: [Fixtures.listening] },
461+ async ($, on) => {
462+ const reads = Fixtures.policyReads(on, Fixtures.MANAGED_POLICY)
463+ const evaluations = Fixtures.checksAnswered(on, Fixtures.ASKED)
464+
465+ expect(await $.tool.check(Fixtures.CHECKED)).toEqual(Fixtures.ASKED)
466+
467+ expect({ reads: reads(), evaluations: evaluations() }).toEqual({
468+ reads: 0,
469+ evaluations: 1,
470+ })
471+ },
472+ )
473+
458474 test('with none of their plugins the verdict passes once', async ($, on) => {
459475 const reads = Fixtures.policyReads(on, Fixtures.MANAGED_POLICY)
460476 const evaluations = Fixtures.checksAnswered(on, Fixtures.RULE_DENY)