SaylorTwift HF Staff commited on
Commit
bee0296
·
verified ·
1 Parent(s): fcfabd3

Add files using upload-large-folder tool

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. mods/sec-default/.claude-plugin/plugin.json +8 -0
  2. mods/sec-default/hooks/hooks.json +4 -0
  3. mods/sec-default/hooks/index.ts +6 -0
  4. mods/sec-default/hooks/past-users/index.ts +5 -0
  5. mods/sec-default/hooks/past-users/past-users.ts +23 -0
  6. mods/sec-default/hooks/policy/create-policy-memo/create-policy-memo.ts +28 -0
  7. mods/sec-default/hooks/policy/decided-by-policy.ts +15 -0
  8. mods/sec-default/hooks/policy/has-mcp-allowlist.ts +11 -0
  9. mods/sec-default/hooks/policy/index.ts +8 -0
  10. mods/sec-default/hooks/policy/managed-tools-restored/index.ts +4 -0
  11. mods/sec-default/hooks/policy/managed-tools-restored/managed-tools-restored.ts +33 -0
  12. mods/sec-default/hooks/policy/policy-memo-ms.ts +7 -0
  13. mods/sec-default/hooks/policy/source.ts +4 -0
  14. mods/sec-default/hooks/register.ts +61 -0
  15. mods/sec-default/hooks/tool-register-refusal/index.ts +3 -0
  16. mods/sec-default/hooks/tool-register-refusal/tool-register-refusal.ts +6 -0
  17. mods/sec-default/tests/register.test.ts +309 -0
  18. mods/telemetry/hooks/entries/checked-props/checked-props.ts +41 -0
  19. mods/telemetry/hooks/entries/checked-props/index.ts +3 -0
  20. mods/telemetry/hooks/entries/checked-value/checked-value.ts +74 -0
  21. mods/telemetry/hooks/entries/checked-value/index.ts +3 -0
  22. mods/telemetry/hooks/entries/choice-token/choice-token.ts +8 -0
  23. mods/telemetry/hooks/entries/choice-token/index.ts +3 -0
  24. mods/telemetry/hooks/entries/choices-limit/choices-limit.ts +4 -0
  25. mods/telemetry/hooks/entries/choices-limit/index.ts +3 -0
  26. mods/telemetry/hooks/entries/core-event-prefix/core-event-prefix.ts +5 -0
  27. mods/telemetry/hooks/entries/core-event-prefix/index.ts +3 -0
  28. mods/telemetry/hooks/entries/event-prefix/event-prefix.ts +7 -0
  29. mods/telemetry/hooks/entries/event-prefix/index.ts +3 -0
  30. mods/telemetry/hooks/entries/feature-prefix/feature-prefix.ts +7 -0
  31. mods/telemetry/hooks/entries/feature-prefix/index.ts +3 -0
  32. mods/telemetry/hooks/entries/fields-of/fields-of.ts +16 -0
  33. mods/telemetry/hooks/entries/fields-of/index.ts +3 -0
  34. mods/telemetry/hooks/entries/fields/fields.ts +8 -0
  35. mods/telemetry/hooks/entries/fields/index.ts +3 -0
  36. mods/telemetry/hooks/entries/is-mark-kind/index.ts +3 -0
  37. mods/telemetry/hooks/entries/is-mark-kind/is-mark-kind.ts +11 -0
  38. mods/telemetry/hooks/entries/is-record/index.ts +3 -0
  39. mods/telemetry/hooks/entries/is-record/is-record.ts +8 -0
  40. mods/telemetry/hooks/entries/mark-fields-of/index.ts +3 -0
  41. mods/telemetry/hooks/entries/mark-fields-of/mark-fields-of.ts +28 -0
  42. mods/telemetry/hooks/entries/mark-kinds/index.ts +3 -0
  43. mods/telemetry/hooks/entries/mark-kinds/mark-kinds.ts +6 -0
  44. mods/telemetry/hooks/entries/mark/index.ts +3 -0
  45. mods/telemetry/hooks/entries/mark/mark.ts +13 -0
  46. mods/telemetry/hooks/entries/method/index.ts +3 -0
  47. mods/telemetry/hooks/entries/method/method.ts +4 -0
  48. mods/telemetry/hooks/entries/prop-limit/index.ts +3 -0
  49. mods/telemetry/hooks/entries/prop-limit/prop-limit.ts +4 -0
  50. mods/telemetry/hooks/entries/refusal/index.ts +3 -0
mods/sec-default/.claude-plugin/plugin.json ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "sec-default",
3
+ "version": "0.1.0",
4
+ "description": "Security default for organizations: seated outermost, it keeps the organization's classic hooks, prompt content, settings and tool policy out of reach of the plugins a person installs, and adds no policy of its own.",
5
+ "author": {
6
+ "name": "Anthropic"
7
+ }
8
+ }
mods/sec-default/hooks/hooks.json ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ {
2
+ "description": "Security default: from the outermost seat, continues past the user tier on the organization's classic hooks, prompt content, settings and subjects, refuses a user-tier tool.register under an MCP allowlist, and restores the organization's tools in tool.list",
3
+ "modules": ["./register.ts"]
4
+ }
mods/sec-default/hooks/index.ts ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ export * from './past-users'
2
+ export * from './policy'
3
+ export * from './register.js'
4
+ export * from './tool-register-refusal'
5
+
6
+ export * as default from '.'
mods/sec-default/hooks/past-users/index.ts ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ export * from './past-users.js'
2
+ export * from './provided'
3
+ export * from './user-reachable-tiers'
4
+
5
+ export * as default from '.'
mods/sec-default/hooks/past-users/past-users.ts ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type Provided from './provided'
2
+ import { USER_REACHABLE_TIERS } from './user-reachable-tiers'
3
+
4
+ /**
5
+ * What the `tool.describe`, `command.describe`, `agent.offer` and
6
+ * `agent.spawn` hooks do: an organization's subject continues past users.
7
+ *
8
+ * The subject is the organization's unless its pinned `e.provider.tier` is
9
+ * `user`, `builtin` or `core`: a policy-installed plugin (`prepend`,
10
+ * `append`), the managed folder, a policy MCP server, or no provider at all.
11
+ *
12
+ * @param e the event's input with its pinned `provider`
13
+ * @param next the hook's own continuation, handed whole
14
+ * @returns the result from append inward, or from every tier
15
+ */
16
+ export function pastUsers<E extends Provided.Provided, R>(
17
+ e: E,
18
+ next: Provided.ProvidedNext<E, R>,
19
+ ): Promise<R> {
20
+ const isUsers = USER_REACHABLE_TIERS.includes(e.provider?.tier)
21
+
22
+ return isUsers ? next(e) : next.to(e, 'append')
23
+ }
mods/sec-default/hooks/policy/create-policy-memo/create-policy-memo.ts ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Settings } from 'claude-code'
2
+
3
+ /**
4
+ * A reader of managed policy that serves one read to every caller inside a
5
+ * window after it, rejections included (a failed read still fails closed).
6
+ *
7
+ * @param ttlMs how long a read is served after it starts
8
+ * @param now the clock, for a test
9
+ * @returns `(read) => policy`: `read` runs when nothing fresh is held
10
+ */
11
+ export function createPolicyMemo(
12
+ ttlMs: number,
13
+ now: () => number = Date.now,
14
+ ): (read: () => Promise<Settings>) => Promise<Settings> {
15
+ let heldAt = Number.NEGATIVE_INFINITY
16
+ let held: Promise<Settings> | undefined
17
+
18
+ return read => {
19
+ const isStale = held === undefined || now() - heldAt > ttlMs
20
+
21
+ if (isStale) {
22
+ heldAt = now()
23
+ held = read()
24
+ }
25
+
26
+ return held ?? read()
27
+ }
28
+ }
mods/sec-default/hooks/policy/decided-by-policy.ts ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Settings } from 'claude-code'
2
+
3
+ /**
4
+ * A yes/no read off managed policy that fails closed: true (protect) when
5
+ * the read rejects or deciding throws.
6
+ *
7
+ * @param policy the memoized policy read (createPolicyMemo over the hook's
8
+ * `$.settings.read({ source: "policy" })`)
9
+ * @param decide the answer once the policy is read
10
+ * @returns what decide answers, else true
11
+ */
12
+ export const decidedByPolicy = (
13
+ policy: Promise<Settings>,
14
+ decide: (policy: Settings) => boolean,
15
+ ): Promise<boolean> => policy.then(decide).catch(() => true)
mods/sec-default/hooks/policy/has-mcp-allowlist.ts ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Settings } from 'claude-code'
2
+
3
+ /**
4
+ * Whether managed policy holds an MCP allowlist (allowedMcpServers set at
5
+ * all, empty included): the organization decides which servers add tools.
6
+ *
7
+ * @param policy the managed settings, as `$.settings.read` answers them
8
+ * @returns true when allowedMcpServers is in force
9
+ */
10
+ export const hasMcpAllowlist = (policy: Settings) =>
11
+ Array.isArray(policy.allowedMcpServers)
mods/sec-default/hooks/policy/index.ts ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ export * from './create-policy-memo'
2
+ export * from './decided-by-policy.js'
3
+ export * from './has-mcp-allowlist.js'
4
+ export * from './managed-tools-restored'
5
+ export * from './policy-memo-ms.js'
6
+ export * from './source.js'
7
+
8
+ export * as default from '.'
mods/sec-default/hooks/policy/managed-tools-restored/index.ts ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ export * from './is-org-tool'
2
+ export * from './managed-tools-restored.js'
3
+
4
+ export * as default from '.'
mods/sec-default/hooks/policy/managed-tools-restored/managed-tools-restored.ts ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Settings, ToolInfo, ValueOrDeny } from 'claude-code'
2
+
3
+ import { isOrgTool } from './is-org-tool'
4
+
5
+ /**
6
+ * A `tool.list` answer with the managed servers' tools as the organization's
7
+ * tiers listed them, and every other tool as the user tier left it.
8
+ *
9
+ * A refusal from either listing, or no policy to read, leaves the
10
+ * organization's listing standing whole (fail closed).
11
+ *
12
+ * @param policy the managed settings, or undefined when the read failed
13
+ * @param real the listing past the user tier (`next.to(e, "append")`)
14
+ * @param seen the listing through every tier (`next(e)`)
15
+ * @returns the merged answer
16
+ */
17
+ export function managedToolsRestored(
18
+ policy: Settings | undefined,
19
+ real: ValueOrDeny<ToolInfo[]>,
20
+ seen: ValueOrDeny<ToolInfo[]>,
21
+ ): ValueOrDeny<ToolInfo[]> {
22
+ const isWhole =
23
+ policy === undefined || real.value === undefined || seen.value === undefined
24
+
25
+ return isWhole
26
+ ? real
27
+ : {
28
+ value: [
29
+ ...real.value.filter(tool => isOrgTool(policy, tool.name)),
30
+ ...seen.value.filter(tool => !isOrgTool(policy, tool.name)),
31
+ ],
32
+ }
33
+ }
mods/sec-default/hooks/policy/policy-memo-ms.ts ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ /**
2
+ * How long one policy read serves the decisions after it: a burst of
3
+ * `$.tool.list` and `$.tool.register` calls reads once.
4
+ *
5
+ * A settings change waits this long to be seen.
6
+ */
7
+ export const POLICY_MEMO_MS = 500
mods/sec-default/hooks/policy/source.ts ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ /**
2
+ * What `$.settings.read` is asked for: the managed (policy) source alone.
3
+ */
4
+ export const SOURCE = { source: 'policy' } as const
mods/sec-default/hooks/register.ts ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { On } from 'claude-code'
2
+
3
+ import { pastUsers } from './past-users'
4
+ import Policy from './policy'
5
+ import { TOOL_REGISTER_REFUSAL } from './tool-register-refusal'
6
+
7
+ /**
8
+ * The built-in's hooks, seated outermost: each keeps one control an
9
+ * organization has today out of reach of the plugins a person installs.
10
+ *
11
+ * 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.
14
+ *
15
+ * @param on the engine's registrar
16
+ */
17
+ export function register(on: On) {
18
+ const readPolicy = Policy.createPolicyMemo(Policy.POLICY_MEMO_MS)
19
+
20
+ on('classic.*', ($, e, next) => next.to(e, 'append'))
21
+
22
+ on('prompt.section', ($, e, next) => next.to(e, 'append'))
23
+ on('prompt.context', ($, e, next) => next.to(e, 'append'))
24
+ on('skill.prompt', ($, e, next) => next.to(e, 'append'))
25
+ on('attribution.text', ($, e, next) => next.to(e, 'append'))
26
+
27
+ on('settings.read', ($, e, next) => next.to(e, 'append'))
28
+
29
+ on('tool.describe', ($, e, next) => pastUsers(e, next))
30
+ on('command.describe', ($, e, next) => pastUsers(e, next))
31
+ on('agent.offer', ($, e, next) => pastUsers(e, next))
32
+ on('agent.spawn', ($, e, next) => pastUsers(e, next))
33
+
34
+ on('tool.register', async ($, e, next) => {
35
+ const isOrgs =
36
+ next.origin.tier === 'prepend' || next.origin.tier === 'append'
37
+
38
+ if (isOrgs) {
39
+ return next.to(e, 'append')
40
+ }
41
+
42
+ const isRefused =
43
+ next.origin.tier === 'user' &&
44
+ (await Policy.decidedByPolicy(
45
+ readPolicy(() => $.settings.read(Policy.SOURCE)),
46
+ Policy.hasMcpAllowlist,
47
+ ))
48
+
49
+ return isRefused ? { deny: TOOL_REGISTER_REFUSAL } : next(e)
50
+ })
51
+
52
+ on('tool.list', async ($, e, next) =>
53
+ Policy.managedToolsRestored(
54
+ await readPolicy(() => $.settings.read(Policy.SOURCE)).catch(
55
+ () => undefined,
56
+ ),
57
+ await next.to(e, 'append'),
58
+ await next(e),
59
+ ),
60
+ )
61
+ }
mods/sec-default/hooks/tool-register-refusal/index.ts ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ export * from './tool-register-refusal.js'
2
+
3
+ export * as default from '.'
mods/sec-default/hooks/tool-register-refusal/tool-register-refusal.ts ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ /**
2
+ * Why a plugin outside policy may not add a tool while the organization's
3
+ * MCP allowlist is in force: the setting, then the rule.
4
+ */
5
+ export const TOOL_REGISTER_REFUSAL =
6
+ 'allowedMcpServers (managed): plugins outside policy may not add tools'
mods/sec-default/tests/register.test.ts ADDED
@@ -0,0 +1,309 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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('register', () => {
9
+ test(
10
+ 'under an MCP allowlist a plugin the person installed may not add a tool',
11
+ {
12
+ plugins: [
13
+ Fixtures.registering('suite', 'prepend'),
14
+ Fixtures.registering('mine'),
15
+ Fixtures.registering('bundled', 'builtin'),
16
+ ],
17
+ },
18
+ async ($, on) => {
19
+ on('settings.read', () => ({ value: Fixtures.ALLOWLIST }))
20
+
21
+ const registered = Fixtures.toolsRegistered(on)
22
+
23
+ await $.session.start(Fixtures.SESSION)
24
+
25
+ expect(registered).toEqual(['suite', 'bundled'])
26
+ },
27
+ )
28
+
29
+ test(
30
+ 'with no allowlist, a plugin the person installed adds its tool',
31
+ { plugins: [Fixtures.registering('mine')] },
32
+ async ($, on) => {
33
+ on('settings.read', () => ({ value: Fixtures.NO_ALLOWLIST }))
34
+
35
+ const registered = Fixtures.toolsRegistered(on)
36
+
37
+ await $.session.start(Fixtures.SESSION)
38
+
39
+ expect(registered).toEqual(['mine'])
40
+ },
41
+ )
42
+
43
+ test(
44
+ "an organization's registration passes over a refusing user plugin",
45
+ {
46
+ plugins: [
47
+ Fixtures.registering('suite', 'prepend'),
48
+ Fixtures.denying,
49
+ Fixtures.registering('bundled', 'builtin'),
50
+ ],
51
+ },
52
+ async ($, on) => {
53
+ on('settings.read', () => ({ value: Fixtures.MANAGED_POLICY }))
54
+
55
+ const registered = Fixtures.toolsRegistered(on)
56
+
57
+ await $.session.start(Fixtures.SESSION)
58
+
59
+ expect(
60
+ registered,
61
+ "a built-in's registration still meets the user's deny",
62
+ ).toEqual(['suite'])
63
+ },
64
+ )
65
+
66
+ test(
67
+ 'a policy that cannot be read counts as one in force',
68
+ { plugins: [Fixtures.registering('mine')] },
69
+ async ($, on) => {
70
+ on('settings.read', () => ({ deny: 'managed settings unreadable' }))
71
+
72
+ const registered = Fixtures.toolsRegistered(on)
73
+
74
+ await $.session.start(Fixtures.SESSION)
75
+
76
+ expect(registered).toEqual([])
77
+ },
78
+ )
79
+
80
+ test(
81
+ 'the organization tools are listed as its tiers listed them',
82
+ { plugins: [Fixtures.relabeling, Fixtures.listing] },
83
+ async ($, on) => {
84
+ on('settings.read', () => ({ value: Fixtures.ALLOWLIST }))
85
+ on('tool.list', () => ({ value: [...Fixtures.TOOLS] }))
86
+
87
+ const { text } = await $.command.run(Fixtures.TOOLS_COMMAND)
88
+
89
+ expect(text?.split('\n')).toEqual([
90
+ 'mcp__corp__search: Searches the corp wiki.',
91
+ 'Bash: relabeled',
92
+ ])
93
+ },
94
+ )
95
+
96
+ test(
97
+ 'a server policy delivers itself counts as a tool policy, unlisted',
98
+ { plugins: [Fixtures.relabeling, Fixtures.listing] },
99
+ async ($, on) => {
100
+ on('settings.read', () => ({ value: Fixtures.SERVER_POLICY }))
101
+ on('tool.list', () => ({ value: [...Fixtures.TOOLS] }))
102
+
103
+ const { text } = await $.command.run(Fixtures.TOOLS_COMMAND)
104
+
105
+ expect(text?.split('\n')).toEqual([
106
+ 'mcp__corp__search: Searches the corp wiki.',
107
+ 'Bash: relabeled',
108
+ ])
109
+ },
110
+ )
111
+
112
+ test(
113
+ "with no policy to read the organization's listing stands whole",
114
+ { plugins: [Fixtures.relabeling, Fixtures.listing] },
115
+ async ($, on) => {
116
+ on('settings.read', () => ({ deny: 'settings unreadable' }))
117
+ on('tool.list', () => ({ value: [...Fixtures.TOOLS] }))
118
+
119
+ const { text } = await $.command.run(Fixtures.TOOLS_COMMAND)
120
+
121
+ expect(text?.split('\n')).toEqual([
122
+ 'mcp__corp__search: Searches the corp wiki.',
123
+ 'Bash: Runs a command.',
124
+ ])
125
+ },
126
+ )
127
+
128
+ test(
129
+ 'a prompt section passes over the plugins the person installed',
130
+ { plugins: [Fixtures.dropping, Fixtures.signing] },
131
+ async ($, on) => {
132
+ on('prompt.section', ($, e) => ({ text: e.text }))
133
+
134
+ expect(await $.prompt.section(Fixtures.MEMORY)).toEqual({
135
+ text: 'the org says hi (signed)',
136
+ })
137
+ },
138
+ )
139
+
140
+ test(
141
+ "a user plugin's rewrite of policy is skipped for every other reader",
142
+ {
143
+ plugins: [
144
+ Fixtures.stripping,
145
+ Fixtures.reading,
146
+ Fixtures.registering('mine'),
147
+ ],
148
+ },
149
+ async ($, on) => {
150
+ on('settings.read', () => ({ value: Fixtures.MANAGED_POLICY }))
151
+
152
+ const registered = Fixtures.toolsRegistered(on)
153
+ const { text } = await $.command.run(Fixtures.POLICY_COMMAND)
154
+
155
+ await $.session.start(Fixtures.SESSION)
156
+
157
+ expect(
158
+ JSON.parse(text ?? 'null'),
159
+ "another user plugin's read sees the allowlist the stripper hides",
160
+ ).toEqual(Fixtures.MANAGED_POLICY)
161
+
162
+ expect(
163
+ registered,
164
+ "sec-default's own tool.register hook still reads the allowlist",
165
+ ).toEqual([])
166
+ },
167
+ )
168
+
169
+ test(
170
+ "an organization provider's subject passes over the user plugins",
171
+ { plugins: [Fixtures.marking] },
172
+ async ($, on) => {
173
+ Fixtures.subjectsEchoed(on)
174
+
175
+ for (const provider of Fixtures.ORG_PROVIDERS) {
176
+ expect(
177
+ await $.tool.describe(
178
+ Fixtures.toolDescribed('mcp__corp__search', provider),
179
+ ),
180
+ ).toEqual({ description: 'd' })
181
+
182
+ expect(
183
+ (
184
+ await $.command.describe(
185
+ Fixtures.commandDescribed('suite:deploy', provider),
186
+ )
187
+ ).isHidden,
188
+ ).toBe(false)
189
+
190
+ expect(
191
+ await $.agent.offer(
192
+ Fixtures.agentOffered('suite:reviewer', provider),
193
+ ),
194
+ ).toEqual({ isOffered: true })
195
+
196
+ expect(await $.agent.spawn(Fixtures.agentSpawned(provider))).toEqual({
197
+ model: 'core',
198
+ })
199
+ }
200
+
201
+ for (const provider of Fixtures.USER_REACHABLE_PROVIDERS) {
202
+ expect(
203
+ await $.tool.describe(
204
+ Fixtures.toolDescribed('mcp__mine__search', provider),
205
+ ),
206
+ ).toEqual({ description: 'user: d' })
207
+
208
+ expect(
209
+ (
210
+ await $.command.describe(
211
+ Fixtures.commandDescribed('mine:deploy', provider),
212
+ )
213
+ ).isHidden,
214
+ ).toBe(true)
215
+
216
+ expect(
217
+ await $.agent.offer(Fixtures.agentOffered('reviewer', provider)),
218
+ ).toEqual({ isOffered: false })
219
+
220
+ expect(await $.agent.spawn(Fixtures.agentSpawned(provider))).toEqual({
221
+ model: 'user',
222
+ })
223
+ }
224
+ },
225
+ )
226
+
227
+ test(
228
+ 'an odd provider passes over the user plugins: it fails closed',
229
+ { plugins: [Fixtures.marking] },
230
+ async ($, on) => {
231
+ Fixtures.subjectsEchoed(on)
232
+
233
+ for (const provider of Fixtures.ODD_PROVIDERS) {
234
+ expect(
235
+ await $.tool.describe(Fixtures.toolDescribed('Bash', provider)),
236
+ ).toEqual({ description: 'd' })
237
+
238
+ expect(await $.agent.spawn(Fixtures.agentSpawned(provider))).toEqual({
239
+ model: 'core',
240
+ })
241
+ }
242
+ },
243
+ )
244
+
245
+ test(
246
+ 'a subject decision reads no policy; a burst of listings reads once',
247
+ { plugins: [Fixtures.listing] },
248
+ async ($, on) => {
249
+ const reads = Fixtures.policyReads(on, Fixtures.MANAGED_POLICY)
250
+
251
+ Fixtures.subjectsEchoed(on)
252
+ on('tool.list', () => ({ value: [...Fixtures.TOOLS] }))
253
+
254
+ for (const provider of Fixtures.ORG_PROVIDERS) {
255
+ await $.tool.describe(Fixtures.toolDescribed('mcp__corp__x', provider))
256
+ await $.agent.spawn(Fixtures.agentSpawned(provider))
257
+ }
258
+
259
+ expect(reads()).toBe(0)
260
+
261
+ await Promise.all([
262
+ $.command.run(Fixtures.TOOLS_COMMAND),
263
+ $.command.run(Fixtures.TOOLS_COMMAND),
264
+ $.command.run(Fixtures.TOOLS_COMMAND),
265
+ ])
266
+
267
+ expect(reads()).toBe(1)
268
+ },
269
+ )
270
+
271
+ test(
272
+ 'the refusal a user-tier caller reads names the allowlist',
273
+ {
274
+ plugins: [
275
+ {
276
+ name: 'asking',
277
+ register(on) {
278
+ on('command.run', { command: 'greet' }, $ =>
279
+ $.tool
280
+ .register({
281
+ name: 'greet',
282
+ description: 'Says hello.',
283
+ inputSchema: { type: 'object' },
284
+ })
285
+ .then(
286
+ () => ({ text: 'registered' }),
287
+ (error: unknown) => ({ text: String(error) }),
288
+ ),
289
+ )
290
+ },
291
+ },
292
+ ],
293
+ },
294
+ async ($, on) => {
295
+ on('settings.read', () => ({ value: Fixtures.ALLOWLIST }))
296
+
297
+ const { text } = await $.command.run({
298
+ command: 'greet',
299
+ args: '',
300
+ origin: { kind: 'composer' },
301
+ presentation: Fixtures.FULLSCREEN,
302
+ })
303
+
304
+ expect(text).toEndWith(
305
+ `asking: $.tool.register: ${Hooks.TOOL_REGISTER_REFUSAL}`,
306
+ )
307
+ },
308
+ )
309
+ })
mods/telemetry/hooks/entries/checked-props/checked-props.ts ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { checkedValue } from '../checked-value'
2
+ import { isRecord } from '../is-record'
3
+ import type { Method } from '../method'
4
+ import { PROP_LIMIT } from '../prop-limit'
5
+ import { refusal } from '../refusal'
6
+ import { TOKEN } from '../token'
7
+
8
+ /**
9
+ * An entry's properties as they go into the row, or a refusal naming the
10
+ * first thing wrong: the shape, the count, then each key and value in turn.
11
+ *
12
+ * @param props what the caller passed as `props`
13
+ * @param method the method the properties were passed to, named in a refusal
14
+ * @returns the properties by key, each value checked
15
+ */
16
+ export function checkedProps(
17
+ props: unknown,
18
+ method: Method,
19
+ ): Record<string, string | number | boolean> {
20
+ if (!isRecord(props)) {
21
+ throw refusal('props: an object of properties by key', method)
22
+ }
23
+
24
+ const entries = Object.entries(props)
25
+
26
+ if (entries.length > PROP_LIMIT) {
27
+ throw refusal(`props: at most ${PROP_LIMIT} properties`, method)
28
+ }
29
+
30
+ const checked: Record<string, string | number | boolean> = {}
31
+
32
+ for (const [key, value] of entries) {
33
+ if (!TOKEN.test(key)) {
34
+ throw refusal('props: every key is a snake_case token', method)
35
+ }
36
+
37
+ checked[key] = checkedValue(key, value, method)
38
+ }
39
+
40
+ return checked
41
+ }
mods/telemetry/hooks/entries/checked-props/index.ts ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ export * from './checked-props.js'
2
+
3
+ export * as default from '.'
mods/telemetry/hooks/entries/checked-value/checked-value.ts ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { CHOICE_TOKEN } from '../choice-token'
2
+ import { CHOICES_LIMIT } from '../choices-limit'
3
+ import { isRecord } from '../is-record'
4
+ import type { Method } from '../method'
5
+ import { refusal } from '../refusal'
6
+
7
+ /**
8
+ * One property's value as it goes into the metadata: a finite number, a
9
+ * boolean, or a Choice's value chosen from its token list.
10
+ *
11
+ * A bare string is refused: free text never reaches the row.
12
+ *
13
+ * @param key the property's key, named in a refusal
14
+ * @param value what the caller passed under it
15
+ * @param method the method the property was passed to, named in a refusal
16
+ * @returns the value as stored: the boolean or finite number unchanged, or the
17
+ * chosen member when value is a Choice
18
+ */
19
+ export function checkedValue(
20
+ key: string,
21
+ value: unknown,
22
+ method: Method = 'log',
23
+ ): string | number | boolean {
24
+ if (typeof value === 'boolean') {
25
+ return value
26
+ }
27
+
28
+ if (typeof value === 'number') {
29
+ if (Number.isFinite(value)) {
30
+ return value
31
+ }
32
+
33
+ throw refusal(`props.${key}: a number is finite`, method)
34
+ }
35
+
36
+ if (typeof value === 'string') {
37
+ throw refusal(
38
+ `props.${key}: free text is refused; a string is a Choice, ` +
39
+ `{ value, of: [...] }`,
40
+ method,
41
+ )
42
+ }
43
+
44
+ if (!isRecord(value) || !Array.isArray(value.of)) {
45
+ throw refusal(
46
+ `props.${key}: a value is a finite number, a boolean, or a Choice, ` +
47
+ `{ value, of: [...] }`,
48
+ method,
49
+ )
50
+ }
51
+
52
+ const members = value.of
53
+ const chosen = value.value
54
+
55
+ const isTokenList =
56
+ members.length > 0 &&
57
+ members.length <= CHOICES_LIMIT &&
58
+ members.every(
59
+ member => typeof member === 'string' && CHOICE_TOKEN.test(member),
60
+ )
61
+
62
+ if (!isTokenList) {
63
+ throw refusal(
64
+ `props.${key}.of: a list of 1 to ${CHOICES_LIMIT} ` + `lowercase tokens`,
65
+ method,
66
+ )
67
+ }
68
+
69
+ if (typeof chosen !== 'string' || !members.includes(chosen)) {
70
+ throw refusal(`props.${key}.value: one of the members of \`of\``, method)
71
+ }
72
+
73
+ return chosen
74
+ }
mods/telemetry/hooks/entries/checked-value/index.ts ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ export * from './checked-value.js'
2
+
3
+ export * as default from '.'
mods/telemetry/hooks/entries/choice-token/choice-token.ts ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * The shape of a Choice member: a lowercase token of at most 64 characters,
3
+ * of letters, digits, `_` and `-`.
4
+ *
5
+ * Unlike a name or a key (TOKEN), it may start with a digit (a bucket such
6
+ * as `110_to_143`) and carry a hyphen (a kind such as `merge-base`).
7
+ */
8
+ export const CHOICE_TOKEN = /^[a-z0-9][a-z0-9_-]{0,63}$/
mods/telemetry/hooks/entries/choice-token/index.ts ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ export * from './choice-token.js'
2
+
3
+ export * as default from '.'
mods/telemetry/hooks/entries/choices-limit/choices-limit.ts ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ /**
2
+ * The most members a Choice's list may have.
3
+ */
4
+ export const CHOICES_LIMIT = 32
mods/telemetry/hooks/entries/choices-limit/index.ts ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ export * from './choices-limit.js'
2
+
3
+ export * as default from '.'
mods/telemetry/hooks/entries/core-event-prefix/core-event-prefix.ts ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ /**
2
+ * What the CLI's own event names start with: an event already so named is
3
+ * sent under its own name, so a built-in's port keeps its built-in's row.
4
+ */
5
+ export const CORE_EVENT_PREFIX = 'tengu_'
mods/telemetry/hooks/entries/core-event-prefix/index.ts ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ export * from './core-event-prefix.js'
2
+
3
+ export * as default from '.'
mods/telemetry/hooks/entries/event-prefix/event-prefix.ts ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ /**
2
+ * What an event's name starts with unless the caller named it as the CLI's
3
+ * own (CORE_EVENT_PREFIX): the plugin-event convention.
4
+ *
5
+ * The calling built-in's own name is already in the event it passes.
6
+ */
7
+ export const EVENT_PREFIX = 'tengu_plugin_'
mods/telemetry/hooks/entries/event-prefix/index.ts ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ export * from './event-prefix.js'
2
+
3
+ export * as default from '.'
mods/telemetry/hooks/entries/feature-prefix/feature-prefix.ts ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ /**
2
+ * What every feature mark's event name starts with; the kind follows.
3
+ *
4
+ * The CLI's own feature events are named so, which puts a plugin's marks in
5
+ * the same table as every other feature's.
6
+ */
7
+ export const FEATURE_PREFIX = 'tengu_feature_'
mods/telemetry/hooks/entries/feature-prefix/index.ts ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ export * from './feature-prefix.js'
2
+
3
+ export * as default from '.'
mods/telemetry/hooks/entries/fields-of/fields-of.ts ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { CORE_EVENT_PREFIX } from '../core-event-prefix'
2
+ import { EVENT_PREFIX } from '../event-prefix'
3
+ import type { Fields } from '../fields'
4
+
5
+ /**
6
+ * One checked entry as its fields: an event already named as the CLI's own
7
+ * keeps its name, any other goes under the plugin prefix.
8
+ *
9
+ * @param event the event's name as the caller spelled it
10
+ * @param props the properties as checked
11
+ * @returns the entry's fields, the event name as sent and the props attached
12
+ */
13
+ export const fieldsOf = (event: string, props: Fields['props']): Fields => ({
14
+ name: event.startsWith(CORE_EVENT_PREFIX) ? event : EVENT_PREFIX + event,
15
+ props,
16
+ })
mods/telemetry/hooks/entries/fields-of/index.ts ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ export * from './fields-of.js'
2
+
3
+ export * as default from '.'
mods/telemetry/hooks/entries/fields/fields.ts ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * One entry after the check: the event's full name and the properties as
3
+ * they go into the row's metadata.
4
+ */
5
+ export type Fields = {
6
+ name: string
7
+ props: Readonly<Record<string, string | number | boolean>>
8
+ }
mods/telemetry/hooks/entries/fields/index.ts ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ export type * from './fields.js'
2
+
3
+ export * as default from '.'
mods/telemetry/hooks/entries/is-mark-kind/index.ts ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ export * from './is-mark-kind.js'
2
+
3
+ export * as default from '.'
mods/telemetry/hooks/entries/is-mark-kind/is-mark-kind.ts ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { TelemetryMarkKind } from '../../../types'
2
+ import { MARK_KINDS } from '../mark-kinds'
3
+
4
+ /**
5
+ * Whether the value names one of the three mark kinds.
6
+ *
7
+ * @param value what the caller passed as `kind`
8
+ * @returns whether value names one of the three mark kinds
9
+ */
10
+ export const isMarkKind = (value: unknown): value is TelemetryMarkKind =>
11
+ MARK_KINDS.some(kind => kind === value)
mods/telemetry/hooks/entries/is-record/index.ts ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ export * from './is-record.js'
2
+
3
+ export * as default from '.'
mods/telemetry/hooks/entries/is-record/is-record.ts ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Whether the value is a plain object (not null, not an array).
3
+ *
4
+ * @param value what the caller passed
5
+ * @returns whether the value is a plain object, not null and not an array
6
+ */
7
+ export const isRecord = (value: unknown): value is Record<string, unknown> =>
8
+ typeof value === 'object' && value !== null && !Array.isArray(value)
mods/telemetry/hooks/entries/mark-fields-of/index.ts ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ export * from './mark-fields-of.js'
2
+
3
+ export * as default from '.'
mods/telemetry/hooks/entries/mark-fields-of/mark-fields-of.ts ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { FEATURE_PREFIX } from '../feature-prefix'
2
+ import type { Fields } from '../fields'
3
+ import type { Mark } from '../mark'
4
+
5
+ /**
6
+ * One checked mark as its fields: the CLI's own feature event,
7
+ * `tengu_feature_<kind>` with `feature_name`, and `error_code` when given.
8
+ *
9
+ * The mark's properties merge in as the CLI's feature events merge their
10
+ * extras: after `feature_name` on an ok row, and beneath `feature_name`
11
+ * and `error_code` on a sad or bad one.
12
+ *
13
+ * @param mark the feature, how it went, why when not ok, and its checked
14
+ * properties
15
+ * @returns the event's name and props, ready to log
16
+ */
17
+ export const markFieldsOf = ({
18
+ kind,
19
+ feature,
20
+ reason,
21
+ props,
22
+ }: Mark): Fields => ({
23
+ name: FEATURE_PREFIX + kind,
24
+ props:
25
+ reason === undefined
26
+ ? { feature_name: feature, ...props }
27
+ : { ...props, feature_name: feature, error_code: reason },
28
+ })
mods/telemetry/hooks/entries/mark-kinds/index.ts ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ export * from './mark-kinds.js'
2
+
3
+ export * as default from '.'
mods/telemetry/hooks/entries/mark-kinds/mark-kinds.ts ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ import type { TelemetryMarkKind } from '../../../types'
2
+
3
+ /**
4
+ * The three kinds a mark may be, in the order the feature events name them.
5
+ */
6
+ export const MARK_KINDS: readonly TelemetryMarkKind[] = ['ok', 'sad', 'bad']
mods/telemetry/hooks/entries/mark/index.ts ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ export type * from './mark.js'
2
+
3
+ export * as default from '.'
mods/telemetry/hooks/entries/mark/mark.ts ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { TelemetryMarkKind } from '../../../types'
2
+ import type { Fields } from '../fields'
3
+
4
+ /**
5
+ * One mark past every check: the feature, how it went, why when not ok,
6
+ * and its properties as they go into the row.
7
+ */
8
+ export type Mark = {
9
+ kind: TelemetryMarkKind
10
+ feature: string
11
+ reason?: string
12
+ props: Fields['props']
13
+ }
mods/telemetry/hooks/entries/method/index.ts ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ export type * from './method.js'
2
+
3
+ export * as default from '.'
mods/telemetry/hooks/entries/method/method.ts ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ /**
2
+ * The two methods of `$.telemetry`, named in a refusal.
3
+ */
4
+ export type Method = 'log' | 'mark'
mods/telemetry/hooks/entries/prop-limit/index.ts ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ export * from './prop-limit.js'
2
+
3
+ export * as default from '.'
mods/telemetry/hooks/entries/prop-limit/prop-limit.ts ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ /**
2
+ * The most properties one entry may carry.
3
+ */
4
+ export const PROP_LIMIT = 16
mods/telemetry/hooks/entries/refusal/index.ts ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ export * from './refusal.js'
2
+
3
+ export * as default from '.'