diff --git a/mods/sec-default/.claude-plugin/plugin.json b/mods/sec-default/.claude-plugin/plugin.json new file mode 100644 index 0000000000000000000000000000000000000000..86aea45ab0684ba59ce9907e212ca9c7cbb56616 --- /dev/null +++ b/mods/sec-default/.claude-plugin/plugin.json @@ -0,0 +1,8 @@ +{ + "name": "sec-default", + "version": "0.1.0", + "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.", + "author": { + "name": "Anthropic" + } +} diff --git a/mods/sec-default/hooks/hooks.json b/mods/sec-default/hooks/hooks.json new file mode 100644 index 0000000000000000000000000000000000000000..0342a30c0c7d1f31ea8b75bbe3a515d35c8769db --- /dev/null +++ b/mods/sec-default/hooks/hooks.json @@ -0,0 +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, and restores the organization's tools in tool.list", + "modules": ["./register.ts"] +} diff --git a/mods/sec-default/hooks/index.ts b/mods/sec-default/hooks/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..f9dd0b52a56c0bdc65a8290f649e1df0ccd36af6 --- /dev/null +++ b/mods/sec-default/hooks/index.ts @@ -0,0 +1,6 @@ +export * from './past-users' +export * from './policy' +export * from './register.js' +export * from './tool-register-refusal' + +export * as default from '.' diff --git a/mods/sec-default/hooks/past-users/index.ts b/mods/sec-default/hooks/past-users/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..b6ee2675a4cb778f9706bc576af8c85c165b396f --- /dev/null +++ b/mods/sec-default/hooks/past-users/index.ts @@ -0,0 +1,5 @@ +export * from './past-users.js' +export * from './provided' +export * from './user-reachable-tiers' + +export * as default from '.' diff --git a/mods/sec-default/hooks/past-users/past-users.ts b/mods/sec-default/hooks/past-users/past-users.ts new file mode 100644 index 0000000000000000000000000000000000000000..410a73783e824ed70557e2a27b252669cb3f5e5e --- /dev/null +++ b/mods/sec-default/hooks/past-users/past-users.ts @@ -0,0 +1,23 @@ +import type Provided from './provided' +import { USER_REACHABLE_TIERS } from './user-reachable-tiers' + +/** + * What the `tool.describe`, `command.describe`, `agent.offer` and + * `agent.spawn` hooks do: an organization's subject continues past users. + * + * The subject is the organization's unless its pinned `e.provider.tier` is + * `user`, `builtin` or `core`: a policy-installed plugin (`prepend`, + * `append`), the managed folder, a policy MCP server, or no provider at all. + * + * @param e the event's input with its pinned `provider` + * @param next the hook's own continuation, handed whole + * @returns the result from append inward, or from every tier + */ +export function pastUsers( + e: E, + next: Provided.ProvidedNext, +): Promise { + const isUsers = USER_REACHABLE_TIERS.includes(e.provider?.tier) + + return isUsers ? next(e) : next.to(e, 'append') +} diff --git a/mods/sec-default/hooks/policy/create-policy-memo/create-policy-memo.ts b/mods/sec-default/hooks/policy/create-policy-memo/create-policy-memo.ts new file mode 100644 index 0000000000000000000000000000000000000000..f5c0529ea4e8a1870f1967923fc39fa125715338 --- /dev/null +++ b/mods/sec-default/hooks/policy/create-policy-memo/create-policy-memo.ts @@ -0,0 +1,28 @@ +import type { Settings } from 'claude-code' + +/** + * A reader of managed policy that serves one read to every caller inside a + * window after it, rejections included (a failed read still fails closed). + * + * @param ttlMs how long a read is served after it starts + * @param now the clock, for a test + * @returns `(read) => policy`: `read` runs when nothing fresh is held + */ +export function createPolicyMemo( + ttlMs: number, + now: () => number = Date.now, +): (read: () => Promise) => Promise { + let heldAt = Number.NEGATIVE_INFINITY + let held: Promise | undefined + + return read => { + const isStale = held === undefined || now() - heldAt > ttlMs + + if (isStale) { + heldAt = now() + held = read() + } + + return held ?? read() + } +} diff --git a/mods/sec-default/hooks/policy/decided-by-policy.ts b/mods/sec-default/hooks/policy/decided-by-policy.ts new file mode 100644 index 0000000000000000000000000000000000000000..abb01c56fdff6719096478d9d6bbf17ca82fbe30 --- /dev/null +++ b/mods/sec-default/hooks/policy/decided-by-policy.ts @@ -0,0 +1,15 @@ +import type { Settings } from 'claude-code' + +/** + * A yes/no read off managed policy that fails closed: true (protect) when + * the read rejects or deciding throws. + * + * @param policy the memoized policy read (createPolicyMemo over the hook's + * `$.settings.read({ source: "policy" })`) + * @param decide the answer once the policy is read + * @returns what decide answers, else true + */ +export const decidedByPolicy = ( + policy: Promise, + decide: (policy: Settings) => boolean, +): Promise => policy.then(decide).catch(() => true) diff --git a/mods/sec-default/hooks/policy/has-mcp-allowlist.ts b/mods/sec-default/hooks/policy/has-mcp-allowlist.ts new file mode 100644 index 0000000000000000000000000000000000000000..03e7b44afb10edc37ab3d084d95a0fb3e818b34a --- /dev/null +++ b/mods/sec-default/hooks/policy/has-mcp-allowlist.ts @@ -0,0 +1,11 @@ +import type { Settings } from 'claude-code' + +/** + * Whether managed policy holds an MCP allowlist (allowedMcpServers set at + * all, empty included): the organization decides which servers add tools. + * + * @param policy the managed settings, as `$.settings.read` answers them + * @returns true when allowedMcpServers is in force + */ +export const hasMcpAllowlist = (policy: Settings) => + Array.isArray(policy.allowedMcpServers) diff --git a/mods/sec-default/hooks/policy/index.ts b/mods/sec-default/hooks/policy/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..f101494d8850ed389e0020ae208aec9571d94be2 --- /dev/null +++ b/mods/sec-default/hooks/policy/index.ts @@ -0,0 +1,8 @@ +export * from './create-policy-memo' +export * from './decided-by-policy.js' +export * from './has-mcp-allowlist.js' +export * from './managed-tools-restored' +export * from './policy-memo-ms.js' +export * from './source.js' + +export * as default from '.' diff --git a/mods/sec-default/hooks/policy/managed-tools-restored/index.ts b/mods/sec-default/hooks/policy/managed-tools-restored/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..3a59b3b0baefb2e9645e24fa840f583cd17b3296 --- /dev/null +++ b/mods/sec-default/hooks/policy/managed-tools-restored/index.ts @@ -0,0 +1,4 @@ +export * from './is-org-tool' +export * from './managed-tools-restored.js' + +export * as default from '.' diff --git a/mods/sec-default/hooks/policy/managed-tools-restored/managed-tools-restored.ts b/mods/sec-default/hooks/policy/managed-tools-restored/managed-tools-restored.ts new file mode 100644 index 0000000000000000000000000000000000000000..9e838a4586f3398e04390c9c85570b51831de3f9 --- /dev/null +++ b/mods/sec-default/hooks/policy/managed-tools-restored/managed-tools-restored.ts @@ -0,0 +1,33 @@ +import type { Settings, ToolInfo, ValueOrDeny } from 'claude-code' + +import { isOrgTool } from './is-org-tool' + +/** + * A `tool.list` answer with the managed servers' tools as the organization's + * tiers listed them, and every other tool as the user tier left it. + * + * A refusal from either listing, or no policy to read, leaves the + * organization's listing standing whole (fail closed). + * + * @param policy the managed settings, or undefined when the read failed + * @param real the listing past the user tier (`next.to(e, "append")`) + * @param seen the listing through every tier (`next(e)`) + * @returns the merged answer + */ +export function managedToolsRestored( + policy: Settings | undefined, + real: ValueOrDeny, + seen: ValueOrDeny, +): ValueOrDeny { + const isWhole = + policy === undefined || real.value === undefined || seen.value === undefined + + return isWhole + ? real + : { + value: [ + ...real.value.filter(tool => isOrgTool(policy, tool.name)), + ...seen.value.filter(tool => !isOrgTool(policy, tool.name)), + ], + } +} diff --git a/mods/sec-default/hooks/policy/policy-memo-ms.ts b/mods/sec-default/hooks/policy/policy-memo-ms.ts new file mode 100644 index 0000000000000000000000000000000000000000..812f07cdea83a60df959584038d66a63fe3aa746 --- /dev/null +++ b/mods/sec-default/hooks/policy/policy-memo-ms.ts @@ -0,0 +1,7 @@ +/** + * How long one policy read serves the decisions after it: a burst of + * `$.tool.list` and `$.tool.register` calls reads once. + * + * A settings change waits this long to be seen. + */ +export const POLICY_MEMO_MS = 500 diff --git a/mods/sec-default/hooks/policy/source.ts b/mods/sec-default/hooks/policy/source.ts new file mode 100644 index 0000000000000000000000000000000000000000..3bbe12f1a08c77847c64b5106271ac539d019207 --- /dev/null +++ b/mods/sec-default/hooks/policy/source.ts @@ -0,0 +1,4 @@ +/** + * What `$.settings.read` is asked for: the managed (policy) source alone. + */ +export const SOURCE = { source: 'policy' } as const diff --git a/mods/sec-default/hooks/register.ts b/mods/sec-default/hooks/register.ts new file mode 100644 index 0000000000000000000000000000000000000000..06e0417479c4885f2c58572a27cd463d89737be0 --- /dev/null +++ b/mods/sec-default/hooks/register.ts @@ -0,0 +1,61 @@ +import type { On } from 'claude-code' + +import { pastUsers } from './past-users' +import Policy from './policy' +import { TOOL_REGISTER_REFUSAL } from './tool-register-refusal' + +/** + * The built-in's hooks, seated outermost: each keeps one control an + * organization has today out of reach of the plugins a person installs. + * + * Three moves: continue past the user tier (`next.to(e, "append")`), refuse + * a user-tier caller by name, or pass. Provenance is the event's pinned + * `provider`; policy is `$.settings.read`, memoized per burst; fail closed. + * + * @param on the engine's registrar + */ +export function register(on: On) { + const readPolicy = Policy.createPolicyMemo(Policy.POLICY_MEMO_MS) + + on('classic.*', ($, e, next) => next.to(e, 'append')) + + on('prompt.section', ($, e, next) => next.to(e, 'append')) + on('prompt.context', ($, e, next) => next.to(e, 'append')) + on('skill.prompt', ($, e, next) => next.to(e, 'append')) + on('attribution.text', ($, e, next) => next.to(e, 'append')) + + on('settings.read', ($, e, next) => next.to(e, 'append')) + + on('tool.describe', ($, e, next) => pastUsers(e, next)) + on('command.describe', ($, e, next) => pastUsers(e, next)) + on('agent.offer', ($, e, next) => pastUsers(e, next)) + on('agent.spawn', ($, e, next) => pastUsers(e, next)) + + on('tool.register', async ($, e, next) => { + const isOrgs = + next.origin.tier === 'prepend' || next.origin.tier === 'append' + + if (isOrgs) { + return next.to(e, 'append') + } + + const isRefused = + next.origin.tier === 'user' && + (await Policy.decidedByPolicy( + readPolicy(() => $.settings.read(Policy.SOURCE)), + Policy.hasMcpAllowlist, + )) + + return isRefused ? { deny: TOOL_REGISTER_REFUSAL } : next(e) + }) + + on('tool.list', async ($, e, next) => + Policy.managedToolsRestored( + await readPolicy(() => $.settings.read(Policy.SOURCE)).catch( + () => undefined, + ), + await next.to(e, 'append'), + await next(e), + ), + ) +} diff --git a/mods/sec-default/hooks/tool-register-refusal/index.ts b/mods/sec-default/hooks/tool-register-refusal/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..16d8322bb952b4233ef80a2e8a61b81b15febfd0 --- /dev/null +++ b/mods/sec-default/hooks/tool-register-refusal/index.ts @@ -0,0 +1,3 @@ +export * from './tool-register-refusal.js' + +export * as default from '.' diff --git a/mods/sec-default/hooks/tool-register-refusal/tool-register-refusal.ts b/mods/sec-default/hooks/tool-register-refusal/tool-register-refusal.ts new file mode 100644 index 0000000000000000000000000000000000000000..79b3e259186718af8898c78d2cf3102ca5fef55c --- /dev/null +++ b/mods/sec-default/hooks/tool-register-refusal/tool-register-refusal.ts @@ -0,0 +1,6 @@ +/** + * Why a plugin outside policy may not add a tool while the organization's + * MCP allowlist is in force: the setting, then the rule. + */ +export const TOOL_REGISTER_REFUSAL = + 'allowedMcpServers (managed): plugins outside policy may not add tools' diff --git a/mods/sec-default/tests/register.test.ts b/mods/sec-default/tests/register.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..08cb85d9b54bfe3a4f260b9e39dc0a1ad3252219 --- /dev/null +++ b/mods/sec-default/tests/register.test.ts @@ -0,0 +1,309 @@ +import { describe, expect, test, tier } from 'claude-code/testing' + +import Hooks from '../hooks' +import Fixtures from './fixtures' + +tier('prepend') + +describe('register', () => { + test( + 'under an MCP allowlist a plugin the person installed may not add a tool', + { + plugins: [ + Fixtures.registering('suite', 'prepend'), + Fixtures.registering('mine'), + Fixtures.registering('bundled', 'builtin'), + ], + }, + async ($, on) => { + on('settings.read', () => ({ value: Fixtures.ALLOWLIST })) + + const registered = Fixtures.toolsRegistered(on) + + await $.session.start(Fixtures.SESSION) + + expect(registered).toEqual(['suite', 'bundled']) + }, + ) + + test( + 'with no allowlist, a plugin the person installed adds its tool', + { plugins: [Fixtures.registering('mine')] }, + async ($, on) => { + on('settings.read', () => ({ value: Fixtures.NO_ALLOWLIST })) + + const registered = Fixtures.toolsRegistered(on) + + await $.session.start(Fixtures.SESSION) + + expect(registered).toEqual(['mine']) + }, + ) + + test( + "an organization's registration passes over a refusing user plugin", + { + plugins: [ + Fixtures.registering('suite', 'prepend'), + Fixtures.denying, + Fixtures.registering('bundled', 'builtin'), + ], + }, + async ($, on) => { + on('settings.read', () => ({ value: Fixtures.MANAGED_POLICY })) + + const registered = Fixtures.toolsRegistered(on) + + await $.session.start(Fixtures.SESSION) + + expect( + registered, + "a built-in's registration still meets the user's deny", + ).toEqual(['suite']) + }, + ) + + test( + 'a policy that cannot be read counts as one in force', + { plugins: [Fixtures.registering('mine')] }, + async ($, on) => { + on('settings.read', () => ({ deny: 'managed settings unreadable' })) + + const registered = Fixtures.toolsRegistered(on) + + await $.session.start(Fixtures.SESSION) + + expect(registered).toEqual([]) + }, + ) + + test( + 'the organization tools are listed as its tiers listed them', + { plugins: [Fixtures.relabeling, Fixtures.listing] }, + async ($, on) => { + on('settings.read', () => ({ value: Fixtures.ALLOWLIST })) + on('tool.list', () => ({ value: [...Fixtures.TOOLS] })) + + const { text } = await $.command.run(Fixtures.TOOLS_COMMAND) + + expect(text?.split('\n')).toEqual([ + 'mcp__corp__search: Searches the corp wiki.', + 'Bash: relabeled', + ]) + }, + ) + + test( + 'a server policy delivers itself counts as a tool policy, unlisted', + { plugins: [Fixtures.relabeling, Fixtures.listing] }, + async ($, on) => { + on('settings.read', () => ({ value: Fixtures.SERVER_POLICY })) + on('tool.list', () => ({ value: [...Fixtures.TOOLS] })) + + const { text } = await $.command.run(Fixtures.TOOLS_COMMAND) + + expect(text?.split('\n')).toEqual([ + 'mcp__corp__search: Searches the corp wiki.', + 'Bash: relabeled', + ]) + }, + ) + + test( + "with no policy to read the organization's listing stands whole", + { plugins: [Fixtures.relabeling, Fixtures.listing] }, + async ($, on) => { + on('settings.read', () => ({ deny: 'settings unreadable' })) + on('tool.list', () => ({ value: [...Fixtures.TOOLS] })) + + const { text } = await $.command.run(Fixtures.TOOLS_COMMAND) + + expect(text?.split('\n')).toEqual([ + 'mcp__corp__search: Searches the corp wiki.', + 'Bash: Runs a command.', + ]) + }, + ) + + test( + 'a prompt section passes over the plugins the person installed', + { plugins: [Fixtures.dropping, Fixtures.signing] }, + async ($, on) => { + on('prompt.section', ($, e) => ({ text: e.text })) + + expect(await $.prompt.section(Fixtures.MEMORY)).toEqual({ + text: 'the org says hi (signed)', + }) + }, + ) + + test( + "a user plugin's rewrite of policy is skipped for every other reader", + { + plugins: [ + Fixtures.stripping, + Fixtures.reading, + Fixtures.registering('mine'), + ], + }, + async ($, on) => { + on('settings.read', () => ({ value: Fixtures.MANAGED_POLICY })) + + const registered = Fixtures.toolsRegistered(on) + const { text } = await $.command.run(Fixtures.POLICY_COMMAND) + + await $.session.start(Fixtures.SESSION) + + expect( + JSON.parse(text ?? 'null'), + "another user plugin's read sees the allowlist the stripper hides", + ).toEqual(Fixtures.MANAGED_POLICY) + + expect( + registered, + "sec-default's own tool.register hook still reads the allowlist", + ).toEqual([]) + }, + ) + + test( + "an organization provider's subject passes over the user plugins", + { plugins: [Fixtures.marking] }, + async ($, on) => { + Fixtures.subjectsEchoed(on) + + for (const provider of Fixtures.ORG_PROVIDERS) { + expect( + await $.tool.describe( + Fixtures.toolDescribed('mcp__corp__search', provider), + ), + ).toEqual({ description: 'd' }) + + expect( + ( + await $.command.describe( + Fixtures.commandDescribed('suite:deploy', provider), + ) + ).isHidden, + ).toBe(false) + + expect( + await $.agent.offer( + Fixtures.agentOffered('suite:reviewer', provider), + ), + ).toEqual({ isOffered: true }) + + expect(await $.agent.spawn(Fixtures.agentSpawned(provider))).toEqual({ + model: 'core', + }) + } + + for (const provider of Fixtures.USER_REACHABLE_PROVIDERS) { + expect( + await $.tool.describe( + Fixtures.toolDescribed('mcp__mine__search', provider), + ), + ).toEqual({ description: 'user: d' }) + + expect( + ( + await $.command.describe( + Fixtures.commandDescribed('mine:deploy', provider), + ) + ).isHidden, + ).toBe(true) + + expect( + await $.agent.offer(Fixtures.agentOffered('reviewer', provider)), + ).toEqual({ isOffered: false }) + + expect(await $.agent.spawn(Fixtures.agentSpawned(provider))).toEqual({ + model: 'user', + }) + } + }, + ) + + test( + 'an odd provider passes over the user plugins: it fails closed', + { plugins: [Fixtures.marking] }, + async ($, on) => { + Fixtures.subjectsEchoed(on) + + for (const provider of Fixtures.ODD_PROVIDERS) { + expect( + await $.tool.describe(Fixtures.toolDescribed('Bash', provider)), + ).toEqual({ description: 'd' }) + + expect(await $.agent.spawn(Fixtures.agentSpawned(provider))).toEqual({ + model: 'core', + }) + } + }, + ) + + test( + 'a subject decision reads no policy; a burst of listings reads once', + { plugins: [Fixtures.listing] }, + async ($, on) => { + const reads = Fixtures.policyReads(on, Fixtures.MANAGED_POLICY) + + Fixtures.subjectsEchoed(on) + on('tool.list', () => ({ value: [...Fixtures.TOOLS] })) + + for (const provider of Fixtures.ORG_PROVIDERS) { + await $.tool.describe(Fixtures.toolDescribed('mcp__corp__x', provider)) + await $.agent.spawn(Fixtures.agentSpawned(provider)) + } + + expect(reads()).toBe(0) + + await Promise.all([ + $.command.run(Fixtures.TOOLS_COMMAND), + $.command.run(Fixtures.TOOLS_COMMAND), + $.command.run(Fixtures.TOOLS_COMMAND), + ]) + + expect(reads()).toBe(1) + }, + ) + + test( + 'the refusal a user-tier caller reads names the allowlist', + { + plugins: [ + { + name: 'asking', + register(on) { + on('command.run', { command: 'greet' }, $ => + $.tool + .register({ + name: 'greet', + description: 'Says hello.', + inputSchema: { type: 'object' }, + }) + .then( + () => ({ text: 'registered' }), + (error: unknown) => ({ text: String(error) }), + ), + ) + }, + }, + ], + }, + async ($, on) => { + on('settings.read', () => ({ value: Fixtures.ALLOWLIST })) + + const { text } = await $.command.run({ + command: 'greet', + args: '', + origin: { kind: 'composer' }, + presentation: Fixtures.FULLSCREEN, + }) + + expect(text).toEndWith( + `asking: $.tool.register: ${Hooks.TOOL_REGISTER_REFUSAL}`, + ) + }, + ) +}) diff --git a/mods/telemetry/hooks/entries/checked-props/checked-props.ts b/mods/telemetry/hooks/entries/checked-props/checked-props.ts new file mode 100644 index 0000000000000000000000000000000000000000..e01cf08a64b159683d11d4f21796dbe1517bd215 --- /dev/null +++ b/mods/telemetry/hooks/entries/checked-props/checked-props.ts @@ -0,0 +1,41 @@ +import { checkedValue } from '../checked-value' +import { isRecord } from '../is-record' +import type { Method } from '../method' +import { PROP_LIMIT } from '../prop-limit' +import { refusal } from '../refusal' +import { TOKEN } from '../token' + +/** + * An entry's properties as they go into the row, or a refusal naming the + * first thing wrong: the shape, the count, then each key and value in turn. + * + * @param props what the caller passed as `props` + * @param method the method the properties were passed to, named in a refusal + * @returns the properties by key, each value checked + */ +export function checkedProps( + props: unknown, + method: Method, +): Record { + if (!isRecord(props)) { + throw refusal('props: an object of properties by key', method) + } + + const entries = Object.entries(props) + + if (entries.length > PROP_LIMIT) { + throw refusal(`props: at most ${PROP_LIMIT} properties`, method) + } + + const checked: Record = {} + + for (const [key, value] of entries) { + if (!TOKEN.test(key)) { + throw refusal('props: every key is a snake_case token', method) + } + + checked[key] = checkedValue(key, value, method) + } + + return checked +} diff --git a/mods/telemetry/hooks/entries/checked-props/index.ts b/mods/telemetry/hooks/entries/checked-props/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..8e5c2fae2844fdc461a0e3bb8abd404f540e3feb --- /dev/null +++ b/mods/telemetry/hooks/entries/checked-props/index.ts @@ -0,0 +1,3 @@ +export * from './checked-props.js' + +export * as default from '.' diff --git a/mods/telemetry/hooks/entries/checked-value/checked-value.ts b/mods/telemetry/hooks/entries/checked-value/checked-value.ts new file mode 100644 index 0000000000000000000000000000000000000000..7ea02edf5d79cc4b6780d52e98c846b05ce7ced8 --- /dev/null +++ b/mods/telemetry/hooks/entries/checked-value/checked-value.ts @@ -0,0 +1,74 @@ +import { CHOICE_TOKEN } from '../choice-token' +import { CHOICES_LIMIT } from '../choices-limit' +import { isRecord } from '../is-record' +import type { Method } from '../method' +import { refusal } from '../refusal' + +/** + * One property's value as it goes into the metadata: a finite number, a + * boolean, or a Choice's value chosen from its token list. + * + * A bare string is refused: free text never reaches the row. + * + * @param key the property's key, named in a refusal + * @param value what the caller passed under it + * @param method the method the property was passed to, named in a refusal + * @returns the value as stored: the boolean or finite number unchanged, or the + * chosen member when value is a Choice + */ +export function checkedValue( + key: string, + value: unknown, + method: Method = 'log', +): string | number | boolean { + if (typeof value === 'boolean') { + return value + } + + if (typeof value === 'number') { + if (Number.isFinite(value)) { + return value + } + + throw refusal(`props.${key}: a number is finite`, method) + } + + if (typeof value === 'string') { + throw refusal( + `props.${key}: free text is refused; a string is a Choice, ` + + `{ value, of: [...] }`, + method, + ) + } + + if (!isRecord(value) || !Array.isArray(value.of)) { + throw refusal( + `props.${key}: a value is a finite number, a boolean, or a Choice, ` + + `{ value, of: [...] }`, + method, + ) + } + + const members = value.of + const chosen = value.value + + const isTokenList = + members.length > 0 && + members.length <= CHOICES_LIMIT && + members.every( + member => typeof member === 'string' && CHOICE_TOKEN.test(member), + ) + + if (!isTokenList) { + throw refusal( + `props.${key}.of: a list of 1 to ${CHOICES_LIMIT} ` + `lowercase tokens`, + method, + ) + } + + if (typeof chosen !== 'string' || !members.includes(chosen)) { + throw refusal(`props.${key}.value: one of the members of \`of\``, method) + } + + return chosen +} diff --git a/mods/telemetry/hooks/entries/checked-value/index.ts b/mods/telemetry/hooks/entries/checked-value/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..319b97e7df39de78dc5f23308515ac4e854c23d4 --- /dev/null +++ b/mods/telemetry/hooks/entries/checked-value/index.ts @@ -0,0 +1,3 @@ +export * from './checked-value.js' + +export * as default from '.' diff --git a/mods/telemetry/hooks/entries/choice-token/choice-token.ts b/mods/telemetry/hooks/entries/choice-token/choice-token.ts new file mode 100644 index 0000000000000000000000000000000000000000..c713470298890e52279d5d24f951feeb3a85516a --- /dev/null +++ b/mods/telemetry/hooks/entries/choice-token/choice-token.ts @@ -0,0 +1,8 @@ +/** + * The shape of a Choice member: a lowercase token of at most 64 characters, + * of letters, digits, `_` and `-`. + * + * Unlike a name or a key (TOKEN), it may start with a digit (a bucket such + * as `110_to_143`) and carry a hyphen (a kind such as `merge-base`). + */ +export const CHOICE_TOKEN = /^[a-z0-9][a-z0-9_-]{0,63}$/ diff --git a/mods/telemetry/hooks/entries/choice-token/index.ts b/mods/telemetry/hooks/entries/choice-token/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..6cccdbd5531548b61b660c16a3d2182474a8c244 --- /dev/null +++ b/mods/telemetry/hooks/entries/choice-token/index.ts @@ -0,0 +1,3 @@ +export * from './choice-token.js' + +export * as default from '.' diff --git a/mods/telemetry/hooks/entries/choices-limit/choices-limit.ts b/mods/telemetry/hooks/entries/choices-limit/choices-limit.ts new file mode 100644 index 0000000000000000000000000000000000000000..d6071e88dd987c72565bea2125387eee2d3c34e6 --- /dev/null +++ b/mods/telemetry/hooks/entries/choices-limit/choices-limit.ts @@ -0,0 +1,4 @@ +/** + * The most members a Choice's list may have. + */ +export const CHOICES_LIMIT = 32 diff --git a/mods/telemetry/hooks/entries/choices-limit/index.ts b/mods/telemetry/hooks/entries/choices-limit/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..f83ac2b4ba885c317bb6d98660a017e68b4c0802 --- /dev/null +++ b/mods/telemetry/hooks/entries/choices-limit/index.ts @@ -0,0 +1,3 @@ +export * from './choices-limit.js' + +export * as default from '.' diff --git a/mods/telemetry/hooks/entries/core-event-prefix/core-event-prefix.ts b/mods/telemetry/hooks/entries/core-event-prefix/core-event-prefix.ts new file mode 100644 index 0000000000000000000000000000000000000000..faa9f3f45b84be655e2ccf938a847ac4e8d22797 --- /dev/null +++ b/mods/telemetry/hooks/entries/core-event-prefix/core-event-prefix.ts @@ -0,0 +1,5 @@ +/** + * What the CLI's own event names start with: an event already so named is + * sent under its own name, so a built-in's port keeps its built-in's row. + */ +export const CORE_EVENT_PREFIX = 'tengu_' diff --git a/mods/telemetry/hooks/entries/core-event-prefix/index.ts b/mods/telemetry/hooks/entries/core-event-prefix/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..3a821a39117fb3d6de99bd3d62f5bc59a8c8fd81 --- /dev/null +++ b/mods/telemetry/hooks/entries/core-event-prefix/index.ts @@ -0,0 +1,3 @@ +export * from './core-event-prefix.js' + +export * as default from '.' diff --git a/mods/telemetry/hooks/entries/event-prefix/event-prefix.ts b/mods/telemetry/hooks/entries/event-prefix/event-prefix.ts new file mode 100644 index 0000000000000000000000000000000000000000..f2cdb8ef79cbecc15dad65c1b1ff22dd599d48fb --- /dev/null +++ b/mods/telemetry/hooks/entries/event-prefix/event-prefix.ts @@ -0,0 +1,7 @@ +/** + * What an event's name starts with unless the caller named it as the CLI's + * own (CORE_EVENT_PREFIX): the plugin-event convention. + * + * The calling built-in's own name is already in the event it passes. + */ +export const EVENT_PREFIX = 'tengu_plugin_' diff --git a/mods/telemetry/hooks/entries/event-prefix/index.ts b/mods/telemetry/hooks/entries/event-prefix/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..d1f98bafaa5a83b52e07b1fb7a26b539156a7d20 --- /dev/null +++ b/mods/telemetry/hooks/entries/event-prefix/index.ts @@ -0,0 +1,3 @@ +export * from './event-prefix.js' + +export * as default from '.' diff --git a/mods/telemetry/hooks/entries/feature-prefix/feature-prefix.ts b/mods/telemetry/hooks/entries/feature-prefix/feature-prefix.ts new file mode 100644 index 0000000000000000000000000000000000000000..6171f6be22ce8738c0e0eba221fac4f36c22fb9b --- /dev/null +++ b/mods/telemetry/hooks/entries/feature-prefix/feature-prefix.ts @@ -0,0 +1,7 @@ +/** + * What every feature mark's event name starts with; the kind follows. + * + * The CLI's own feature events are named so, which puts a plugin's marks in + * the same table as every other feature's. + */ +export const FEATURE_PREFIX = 'tengu_feature_' diff --git a/mods/telemetry/hooks/entries/feature-prefix/index.ts b/mods/telemetry/hooks/entries/feature-prefix/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..57ab8343f7b46afbd550ab5fdea793f49089ca21 --- /dev/null +++ b/mods/telemetry/hooks/entries/feature-prefix/index.ts @@ -0,0 +1,3 @@ +export * from './feature-prefix.js' + +export * as default from '.' diff --git a/mods/telemetry/hooks/entries/fields-of/fields-of.ts b/mods/telemetry/hooks/entries/fields-of/fields-of.ts new file mode 100644 index 0000000000000000000000000000000000000000..22d9905cf1b808d2f7bb9cd7cdfcb153f4f7faab --- /dev/null +++ b/mods/telemetry/hooks/entries/fields-of/fields-of.ts @@ -0,0 +1,16 @@ +import { CORE_EVENT_PREFIX } from '../core-event-prefix' +import { EVENT_PREFIX } from '../event-prefix' +import type { Fields } from '../fields' + +/** + * One checked entry as its fields: an event already named as the CLI's own + * keeps its name, any other goes under the plugin prefix. + * + * @param event the event's name as the caller spelled it + * @param props the properties as checked + * @returns the entry's fields, the event name as sent and the props attached + */ +export const fieldsOf = (event: string, props: Fields['props']): Fields => ({ + name: event.startsWith(CORE_EVENT_PREFIX) ? event : EVENT_PREFIX + event, + props, +}) diff --git a/mods/telemetry/hooks/entries/fields-of/index.ts b/mods/telemetry/hooks/entries/fields-of/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..266ba2970dc0c4ac0292659b93f25cb69ea7e54a --- /dev/null +++ b/mods/telemetry/hooks/entries/fields-of/index.ts @@ -0,0 +1,3 @@ +export * from './fields-of.js' + +export * as default from '.' diff --git a/mods/telemetry/hooks/entries/fields/fields.ts b/mods/telemetry/hooks/entries/fields/fields.ts new file mode 100644 index 0000000000000000000000000000000000000000..0b3b81921876e40e9b1f19e7cdc36729174ee8f5 --- /dev/null +++ b/mods/telemetry/hooks/entries/fields/fields.ts @@ -0,0 +1,8 @@ +/** + * One entry after the check: the event's full name and the properties as + * they go into the row's metadata. + */ +export type Fields = { + name: string + props: Readonly> +} diff --git a/mods/telemetry/hooks/entries/fields/index.ts b/mods/telemetry/hooks/entries/fields/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..f650daefbd826208774eb1808a204277f1a665d9 --- /dev/null +++ b/mods/telemetry/hooks/entries/fields/index.ts @@ -0,0 +1,3 @@ +export type * from './fields.js' + +export * as default from '.' diff --git a/mods/telemetry/hooks/entries/is-mark-kind/index.ts b/mods/telemetry/hooks/entries/is-mark-kind/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..f096be79d6cb2209bba4e83e176bbd1b634980b4 --- /dev/null +++ b/mods/telemetry/hooks/entries/is-mark-kind/index.ts @@ -0,0 +1,3 @@ +export * from './is-mark-kind.js' + +export * as default from '.' diff --git a/mods/telemetry/hooks/entries/is-mark-kind/is-mark-kind.ts b/mods/telemetry/hooks/entries/is-mark-kind/is-mark-kind.ts new file mode 100644 index 0000000000000000000000000000000000000000..a5de8e2b1be7f8133465ad287c3c3c5316d0eca6 --- /dev/null +++ b/mods/telemetry/hooks/entries/is-mark-kind/is-mark-kind.ts @@ -0,0 +1,11 @@ +import type { TelemetryMarkKind } from '../../../types' +import { MARK_KINDS } from '../mark-kinds' + +/** + * Whether the value names one of the three mark kinds. + * + * @param value what the caller passed as `kind` + * @returns whether value names one of the three mark kinds + */ +export const isMarkKind = (value: unknown): value is TelemetryMarkKind => + MARK_KINDS.some(kind => kind === value) diff --git a/mods/telemetry/hooks/entries/is-record/index.ts b/mods/telemetry/hooks/entries/is-record/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..b3255fa137aac45bd1e3619cd5b89e35bb672011 --- /dev/null +++ b/mods/telemetry/hooks/entries/is-record/index.ts @@ -0,0 +1,3 @@ +export * from './is-record.js' + +export * as default from '.' diff --git a/mods/telemetry/hooks/entries/is-record/is-record.ts b/mods/telemetry/hooks/entries/is-record/is-record.ts new file mode 100644 index 0000000000000000000000000000000000000000..db0f7cdb3deba3a6b300d8e7c30bcaa6f02352a2 --- /dev/null +++ b/mods/telemetry/hooks/entries/is-record/is-record.ts @@ -0,0 +1,8 @@ +/** + * Whether the value is a plain object (not null, not an array). + * + * @param value what the caller passed + * @returns whether the value is a plain object, not null and not an array + */ +export const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value) diff --git a/mods/telemetry/hooks/entries/mark-fields-of/index.ts b/mods/telemetry/hooks/entries/mark-fields-of/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..4b210cd485dc2b6a31d8c27106e0bdec5913466e --- /dev/null +++ b/mods/telemetry/hooks/entries/mark-fields-of/index.ts @@ -0,0 +1,3 @@ +export * from './mark-fields-of.js' + +export * as default from '.' diff --git a/mods/telemetry/hooks/entries/mark-fields-of/mark-fields-of.ts b/mods/telemetry/hooks/entries/mark-fields-of/mark-fields-of.ts new file mode 100644 index 0000000000000000000000000000000000000000..e2a78b14dd7820b852aaa19d5ba1bdc1c79c065a --- /dev/null +++ b/mods/telemetry/hooks/entries/mark-fields-of/mark-fields-of.ts @@ -0,0 +1,28 @@ +import { FEATURE_PREFIX } from '../feature-prefix' +import type { Fields } from '../fields' +import type { Mark } from '../mark' + +/** + * One checked mark as its fields: the CLI's own feature event, + * `tengu_feature_` with `feature_name`, and `error_code` when given. + * + * The mark's properties merge in as the CLI's feature events merge their + * extras: after `feature_name` on an ok row, and beneath `feature_name` + * and `error_code` on a sad or bad one. + * + * @param mark the feature, how it went, why when not ok, and its checked + * properties + * @returns the event's name and props, ready to log + */ +export const markFieldsOf = ({ + kind, + feature, + reason, + props, +}: Mark): Fields => ({ + name: FEATURE_PREFIX + kind, + props: + reason === undefined + ? { feature_name: feature, ...props } + : { ...props, feature_name: feature, error_code: reason }, +}) diff --git a/mods/telemetry/hooks/entries/mark-kinds/index.ts b/mods/telemetry/hooks/entries/mark-kinds/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..81be6cf73c75265f872ff3b0d419f289a0103916 --- /dev/null +++ b/mods/telemetry/hooks/entries/mark-kinds/index.ts @@ -0,0 +1,3 @@ +export * from './mark-kinds.js' + +export * as default from '.' diff --git a/mods/telemetry/hooks/entries/mark-kinds/mark-kinds.ts b/mods/telemetry/hooks/entries/mark-kinds/mark-kinds.ts new file mode 100644 index 0000000000000000000000000000000000000000..ede36ad82cb278efbd6117b5c70164e1fc333386 --- /dev/null +++ b/mods/telemetry/hooks/entries/mark-kinds/mark-kinds.ts @@ -0,0 +1,6 @@ +import type { TelemetryMarkKind } from '../../../types' + +/** + * The three kinds a mark may be, in the order the feature events name them. + */ +export const MARK_KINDS: readonly TelemetryMarkKind[] = ['ok', 'sad', 'bad'] diff --git a/mods/telemetry/hooks/entries/mark/index.ts b/mods/telemetry/hooks/entries/mark/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..c2450bf2c9f882fd0c100b405c2ff085c8da35ee --- /dev/null +++ b/mods/telemetry/hooks/entries/mark/index.ts @@ -0,0 +1,3 @@ +export type * from './mark.js' + +export * as default from '.' diff --git a/mods/telemetry/hooks/entries/mark/mark.ts b/mods/telemetry/hooks/entries/mark/mark.ts new file mode 100644 index 0000000000000000000000000000000000000000..0f2a1d64b948bc0a0c7590fb2da4e725708bd997 --- /dev/null +++ b/mods/telemetry/hooks/entries/mark/mark.ts @@ -0,0 +1,13 @@ +import type { TelemetryMarkKind } from '../../../types' +import type { Fields } from '../fields' + +/** + * One mark past every check: the feature, how it went, why when not ok, + * and its properties as they go into the row. + */ +export type Mark = { + kind: TelemetryMarkKind + feature: string + reason?: string + props: Fields['props'] +} diff --git a/mods/telemetry/hooks/entries/method/index.ts b/mods/telemetry/hooks/entries/method/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..800b679910195452bd246f20b63c878728862595 --- /dev/null +++ b/mods/telemetry/hooks/entries/method/index.ts @@ -0,0 +1,3 @@ +export type * from './method.js' + +export * as default from '.' diff --git a/mods/telemetry/hooks/entries/method/method.ts b/mods/telemetry/hooks/entries/method/method.ts new file mode 100644 index 0000000000000000000000000000000000000000..a9c2b77d2a627f0a41cf4624eca3b90b10b77887 --- /dev/null +++ b/mods/telemetry/hooks/entries/method/method.ts @@ -0,0 +1,4 @@ +/** + * The two methods of `$.telemetry`, named in a refusal. + */ +export type Method = 'log' | 'mark' diff --git a/mods/telemetry/hooks/entries/prop-limit/index.ts b/mods/telemetry/hooks/entries/prop-limit/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..7b49b376e393cc8b6c250410d19199ea278d35d5 --- /dev/null +++ b/mods/telemetry/hooks/entries/prop-limit/index.ts @@ -0,0 +1,3 @@ +export * from './prop-limit.js' + +export * as default from '.' diff --git a/mods/telemetry/hooks/entries/prop-limit/prop-limit.ts b/mods/telemetry/hooks/entries/prop-limit/prop-limit.ts new file mode 100644 index 0000000000000000000000000000000000000000..b00144da7ab3d4489d982e04a59402c08a24940f --- /dev/null +++ b/mods/telemetry/hooks/entries/prop-limit/prop-limit.ts @@ -0,0 +1,4 @@ +/** + * The most properties one entry may carry. + */ +export const PROP_LIMIT = 16 diff --git a/mods/telemetry/hooks/entries/refusal/index.ts b/mods/telemetry/hooks/entries/refusal/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..a3405db2148763b8ead33bfdf98fe84a4084cfc1 --- /dev/null +++ b/mods/telemetry/hooks/entries/refusal/index.ts @@ -0,0 +1,3 @@ +export * from './refusal.js' + +export * as default from '.' diff --git a/mods/telemetry/hooks/entries/refusal/refusal.ts b/mods/telemetry/hooks/entries/refusal/refusal.ts new file mode 100644 index 0000000000000000000000000000000000000000..29b570c4f06ccd2f58017b04b1770e81252a048d --- /dev/null +++ b/mods/telemetry/hooks/entries/refusal/refusal.ts @@ -0,0 +1,15 @@ +import type { Method } from '../method' + +/** + * The error a refused entry rejects with, naming the method and what was wrong. + * + * The text carries a key that passed TOKEN or a status code, nothing the caller + * wrote as free text. + * + * @param what the refusal, as the caller reads it + * @param method the method refusing + * @returns the error to reject the call with, naming the method and what was + * wrong + */ +export const refusal = (what: string, method: Method = 'log'): Error => + new Error(`$.telemetry.${method}: ${what}`) diff --git a/mods/telemetry/hooks/entries/row-session/index.ts b/mods/telemetry/hooks/entries/row-session/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..32704bf434928f803b081ced3a9111f4f2675102 --- /dev/null +++ b/mods/telemetry/hooks/entries/row-session/index.ts @@ -0,0 +1,3 @@ +export type * from './row-session.js' + +export * as default from '.' diff --git a/mods/telemetry/hooks/entries/row-session/row-session.ts b/mods/telemetry/hooks/entries/row-session/row-session.ts new file mode 100644 index 0000000000000000000000000000000000000000..f7065c28926abe576042f0f5763ea78af15cff59 --- /dev/null +++ b/mods/telemetry/hooks/entries/row-session/row-session.ts @@ -0,0 +1,9 @@ +/** + * What one row says of the session that sent it: its id, its model, and + * the build's user type (`ant` or `external`) as the environment names it. + */ +export type RowSession = { + sessionId: string + model: string + userType: string +} diff --git a/mods/telemetry/hooks/entries/token/index.ts b/mods/telemetry/hooks/entries/token/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..79e7863d8afe8316af1b03289568f7a5ffe3d9bd --- /dev/null +++ b/mods/telemetry/hooks/entries/token/index.ts @@ -0,0 +1,3 @@ +export * from './token.js' + +export * as default from '.' diff --git a/mods/telemetry/hooks/entries/token/token.ts b/mods/telemetry/hooks/entries/token/token.ts new file mode 100644 index 0000000000000000000000000000000000000000..8767f4a12af0978f4efd8be7ba0a09612cccdcb8 --- /dev/null +++ b/mods/telemetry/hooks/entries/token/token.ts @@ -0,0 +1,5 @@ +/** + * The shape of an event name and a property key: a snake_case token of at + * most 64 characters, starting with a letter (a Choice member: CHOICE_TOKEN). + */ +export const TOKEN = /^[a-z][a-z0-9_]{0,63}$/ diff --git a/mods/telemetry/hooks/is-analytics-off/is-env-set/index.ts b/mods/telemetry/hooks/is-analytics-off/is-env-set/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..863fe80462b8a96204d7e3c10ac46a2c5ee6866c --- /dev/null +++ b/mods/telemetry/hooks/is-analytics-off/is-env-set/index.ts @@ -0,0 +1,3 @@ +export * from './is-env-set.js' + +export * as default from '.' diff --git a/mods/telemetry/tests/fixtures/accepted.ts b/mods/telemetry/tests/fixtures/accepted.ts new file mode 100644 index 0000000000000000000000000000000000000000..14528e6d123d4e77d9c2bcb345a2f1935b59948c --- /dev/null +++ b/mods/telemetry/tests/fixtures/accepted.ts @@ -0,0 +1,11 @@ +import type { HttpResponse } from 'claude-code' + +/** + * The ingest's answer to a batch it took. + */ +export const ACCEPTED: HttpResponse = { + status: 200, + ok: true, + headers: {}, + text: '', +} diff --git a/mods/telemetry/tests/fixtures/analytics-off-environments.ts b/mods/telemetry/tests/fixtures/analytics-off-environments.ts new file mode 100644 index 0000000000000000000000000000000000000000..9dc78410958b89e07c6e90a942f0f286d55a19e8 --- /dev/null +++ b/mods/telemetry/tests/fixtures/analytics-off-environments.ts @@ -0,0 +1,18 @@ +/** + * One environment per switch that turns Claude Code's analytics off: the + * plugin sends nothing under any of them. + */ +export const ANALYTICS_OFF_ENVIRONMENTS: readonly Readonly< + Record +>[] = [ + { DISABLE_TELEMETRY: '0' }, + { CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1' }, + { DO_NOT_TRACK: 'true' }, + { CLAUDE_CODE_USE_BEDROCK: '1' }, + { CLAUDE_CODE_USE_VERTEX: 'yes' }, + { CLAUDE_CODE_USE_FOUNDRY: 'on' }, + { CLAUDE_CODE_USE_ANTHROPIC_AWS: 'TRUE' }, + { CLAUDE_CODE_USE_ANTHROPIC_GOOGLE_CLOUD: '1' }, + { CLAUDE_CODE_USE_MANTLE: '1' }, + { CLAUDE_CODE_CUSTOM_OAUTH_URL: 'https://auth.example.invalid' }, +] diff --git a/mods/telemetry/tests/fixtures/base64-alphabet.ts b/mods/telemetry/tests/fixtures/base64-alphabet.ts new file mode 100644 index 0000000000000000000000000000000000000000..f0e24565f5c61ac37d04dc45bc22ea94f5d5de9f --- /dev/null +++ b/mods/telemetry/tests/fixtures/base64-alphabet.ts @@ -0,0 +1,5 @@ +/** + * The base64 digits in value order. + */ +export const BASE64_ALPHABET = + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/' diff --git a/mods/telemetry/tests/fixtures/base64-decoded.ts b/mods/telemetry/tests/fixtures/base64-decoded.ts new file mode 100644 index 0000000000000000000000000000000000000000..8e83a7f13a72ff3f07243a9aac3f7c76638fb11c --- /dev/null +++ b/mods/telemetry/tests/fixtures/base64-decoded.ts @@ -0,0 +1,23 @@ +import { BASE64_ALPHABET } from './base64-alphabet.js' +import { BASE64_DIGIT_BITS } from './base64-digit-bits.js' + +/** + * The text a base64 string encodes, byte for byte (the rows carry ASCII + * JSON), so a test reads a batch's `additional_metadata` back. + * + * @param encoded the base64 text + * @returns the decoded text + */ +export const base64Decoded = (encoded: string): string => + ( + [...encoded.replace(/=+$/, '')] + .map(digit => + BASE64_ALPHABET.indexOf(digit) + .toString(2) + .padStart(BASE64_DIGIT_BITS, '0'), + ) + .join('') + .match(/[01]{8}/g) ?? [] + ) + .map(byte => String.fromCharCode(parseInt(byte, 2))) + .join('') diff --git a/mods/telemetry/tests/fixtures/base64-digit-bits.ts b/mods/telemetry/tests/fixtures/base64-digit-bits.ts new file mode 100644 index 0000000000000000000000000000000000000000..9cddc22211837cf8a24da83ce3e87e825f6f6863 --- /dev/null +++ b/mods/telemetry/tests/fixtures/base64-digit-bits.ts @@ -0,0 +1,4 @@ +/** + * How many bits one base64 digit carries. + */ +export const BASE64_DIGIT_BITS = 6 diff --git a/mods/telemetry/tests/fixtures/batch-of.ts b/mods/telemetry/tests/fixtures/batch-of.ts new file mode 100644 index 0000000000000000000000000000000000000000..4af5351dfa7614d9ffeb0f2267094839ad67f090 --- /dev/null +++ b/mods/telemetry/tests/fixtures/batch-of.ts @@ -0,0 +1,12 @@ +import type { Args } from 'claude-code' + +import type { IngestBatch } from './ingest-batch.js' + +/** + * The batch a post to the ingest carries, as it was sent. + * + * @param post the `http.fetch` the plugin made + * @returns the body, parsed + */ +export const batchOf = (post: Args<'http.fetch'>): IngestBatch => + JSON.parse(String(post.init?.body)) diff --git a/mods/telemetry/tests/fixtures/bearer.ts b/mods/telemetry/tests/fixtures/bearer.ts new file mode 100644 index 0000000000000000000000000000000000000000..7c79d0878bc7b261cbc3eecf4278f01945aa411e --- /dev/null +++ b/mods/telemetry/tests/fixtures/bearer.ts @@ -0,0 +1,9 @@ +import type { SessionAuthorization } from 'claude-code' + +/** + * The credential a session signed in first party holds, by its handle. + */ +export const BEARER: SessionAuthorization = { + handle: 'the-handle', + kind: 'bearer', +} diff --git a/mods/telemetry/tests/fixtures/first-party-session.ts b/mods/telemetry/tests/fixtures/first-party-session.ts new file mode 100644 index 0000000000000000000000000000000000000000..0af68256c5b292efde7444dcd1898b70af0636d6 --- /dev/null +++ b/mods/telemetry/tests/fixtures/first-party-session.ts @@ -0,0 +1,33 @@ +import type { Args, HttpResponse, On, SessionAuthorization } from 'claude-code' + +import { ACCEPTED } from './accepted.js' +import { BEARER } from './bearer.js' + +/** + * Answers what the telemetry plugin reads of a session signed in first + * party, and keeps each post the ingest accepts. + * + * @param on the test's `on` + * @param authorization the credential the session holds + * @param answer what the ingest answers each post; accepted by default + * @returns each post, as it was made + */ +export function firstPartySession( + on: On, + authorization: SessionAuthorization = BEARER, + answer: HttpResponse = ACCEPTED, +) { + const posts: Args<'http.fetch'>[] = [] + + on('session.id', () => ({ value: 'the-session' })) + on('session.model', () => ({ value: 'the-model' })) + on('session.authorize', () => ({ value: authorization })) + + on('http.fetch', ($, e) => { + posts.push(e) + + return { value: answer } + }) + + return posts +} diff --git a/mods/telemetry/tests/fixtures/fullscreen.ts b/mods/telemetry/tests/fixtures/fullscreen.ts new file mode 100644 index 0000000000000000000000000000000000000000..2abe8ba8b162e7f07e9e9c89c90303b58779e212 --- /dev/null +++ b/mods/telemetry/tests/fixtures/fullscreen.ts @@ -0,0 +1,10 @@ +import type { CommandPresentation } from 'claude-code' + +/** + * Where a typed command's answer shows in these tests: the fullscreen + * layout on a 160-column terminal. + */ +export const FULLSCREEN: CommandPresentation = { + isFullscreen: true, + columns: 160, +} diff --git a/mods/telemetry/tests/fixtures/index.ts b/mods/telemetry/tests/fixtures/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..df60764d3a0ebb72ded1934171b22877f9b86048 --- /dev/null +++ b/mods/telemetry/tests/fixtures/index.ts @@ -0,0 +1,20 @@ +export * from './accepted.js' +export * from './analytics-off-environments.js' +export * from './base64-alphabet.js' +export * from './base64-decoded.js' +export * from './base64-digit-bits.js' +export * from './batch-of.js' +export * from './bearer.js' +export * from './first-party-session.js' +export * from './fullscreen.js' +export type * from './ingest-batch.js' +export type * from './ingest-event.js' +export * from './mark.js' +export * from './marking.js' +export * from './record.js' +export * from './recording.js' +export * from './refused.js' +export * from './row-of.js' +export * from './survey-answer.js' + +export * as default from '.' diff --git a/mods/telemetry/tests/fixtures/ingest-batch.ts b/mods/telemetry/tests/fixtures/ingest-batch.ts new file mode 100644 index 0000000000000000000000000000000000000000..bf3669b0db8027b156ab37197919c094b061e857 --- /dev/null +++ b/mods/telemetry/tests/fixtures/ingest-batch.ts @@ -0,0 +1,8 @@ +import type { IngestEvent } from './ingest-event.js' + +/** + * The body of one post to the ingest, parsed: its events. + */ +export type IngestBatch = { + events: readonly IngestEvent[] +} diff --git a/mods/telemetry/tests/fixtures/ingest-event.ts b/mods/telemetry/tests/fixtures/ingest-event.ts new file mode 100644 index 0000000000000000000000000000000000000000..5b94d51b55ac9f77732ad648a9c9968ef0d88d98 --- /dev/null +++ b/mods/telemetry/tests/fixtures/ingest-event.ts @@ -0,0 +1,7 @@ +/** + * One event of a batch posted to the ingest: its type and its data fields. + */ +export type IngestEvent = { + event_type: string + event_data: Record +} diff --git a/mods/telemetry/tests/fixtures/mark.ts b/mods/telemetry/tests/fixtures/mark.ts new file mode 100644 index 0000000000000000000000000000000000000000..e12234ae19967e103d9416a4f21792003dc1c49a --- /dev/null +++ b/mods/telemetry/tests/fixtures/mark.ts @@ -0,0 +1,17 @@ +import type { CommandRunInput } from 'claude-code' + +import { FULLSCREEN } from './fullscreen.js' + +/** + * The command that has the marking plugin mark an entry, typed as the + * person would type it; an entry that is no object rides as it is. + * + * @param entry what to mark + * @returns `/mark ` + */ +export const mark = (entry: unknown): CommandRunInput => ({ + command: 'mark', + args: JSON.stringify(entry), + origin: { kind: 'composer' }, + presentation: FULLSCREEN, +}) diff --git a/mods/telemetry/tests/fixtures/marking.ts b/mods/telemetry/tests/fixtures/marking.ts new file mode 100644 index 0000000000000000000000000000000000000000..9599e93cefce7dad2d778fefb9b9fc5c59a1b851 --- /dev/null +++ b/mods/telemetry/tests/fixtures/marking.ts @@ -0,0 +1,17 @@ +import type { Plugin } from 'claude-code/testing' + +/** + * A plugin whose `/mark ` marks the entry through `$.telemetry`, + * answering "sent", or why the mark was refused. + */ +export const marking: Plugin = { + name: 'marking', + register(on) { + on('command.run', { command: 'mark' }, ($, e) => + $.telemetry.mark(JSON.parse(e.args)).then( + () => ({ text: 'sent' }), + (error: unknown) => ({ text: String(error) }), + ), + ) + }, +} diff --git a/mods/telemetry/tests/fixtures/record.ts b/mods/telemetry/tests/fixtures/record.ts new file mode 100644 index 0000000000000000000000000000000000000000..9d48fd686f5f4c46b6e8ee03291b50fd824c9063 --- /dev/null +++ b/mods/telemetry/tests/fixtures/record.ts @@ -0,0 +1,18 @@ +import type { CommandRunInput } from 'claude-code' + +import type { TelemetryLogEntry } from '../../types' +import { FULLSCREEN } from './fullscreen.js' + +/** + * The command that has the recording plugin log an entry, typed as the + * person would type it. + * + * @param entry what to log + * @returns `/record ` + */ +export const record = (entry: TelemetryLogEntry): CommandRunInput => ({ + command: 'record', + args: JSON.stringify(entry), + origin: { kind: 'composer' }, + presentation: FULLSCREEN, +}) diff --git a/mods/telemetry/tests/fixtures/recording.ts b/mods/telemetry/tests/fixtures/recording.ts new file mode 100644 index 0000000000000000000000000000000000000000..ed6ec5608dae3ad286f69c3c3e75fc9aed1aa85d --- /dev/null +++ b/mods/telemetry/tests/fixtures/recording.ts @@ -0,0 +1,17 @@ +import type { Plugin } from 'claude-code/testing' + +/** + * A plugin whose `/record ` logs the entry through `$.telemetry`, + * answering "sent", or why the row was refused. + */ +export const recording: Plugin = { + name: 'recording', + register(on) { + on('command.run', { command: 'record' }, ($, e) => + $.telemetry.log(JSON.parse(e.args)).then( + () => ({ text: 'sent' }), + (error: unknown) => ({ text: String(error) }), + ), + ) + }, +} diff --git a/mods/telemetry/tests/fixtures/refused.ts b/mods/telemetry/tests/fixtures/refused.ts new file mode 100644 index 0000000000000000000000000000000000000000..334f1aba3870a942ed72bc52e85ebfc736a1bd37 --- /dev/null +++ b/mods/telemetry/tests/fixtures/refused.ts @@ -0,0 +1,11 @@ +import type { HttpResponse } from 'claude-code' + +/** + * The ingest's answer while it is down. + */ +export const REFUSED: HttpResponse = { + status: 500, + ok: false, + headers: {}, + text: '', +} diff --git a/mods/telemetry/tests/fixtures/row-of.ts b/mods/telemetry/tests/fixtures/row-of.ts new file mode 100644 index 0000000000000000000000000000000000000000..22f068858fa77110f05d28cc85ace896cc041c8b --- /dev/null +++ b/mods/telemetry/tests/fixtures/row-of.ts @@ -0,0 +1,29 @@ +import type { Args } from 'claude-code' + +import { base64Decoded } from './base64-decoded.js' +import { batchOf } from './batch-of.js' + +/** + * The one row a post to the ingest carries, held steady for a test: its + * random id and timestamp dropped, `hasTimestamp` and `metadata` decoded. + * + * @param post the `http.fetch` the plugin made + * @returns the row's type, whether it was stamped, its fields and metadata + */ +export function rowOf(post: Args<'http.fetch'>): unknown { + const [event] = batchOf(post).events + + const { + event_id: _eventId, + client_timestamp, + additional_metadata, + ...rest + } = event?.event_data ?? {} + + return { + event_type: event?.event_type, + hasTimestamp: typeof client_timestamp === 'string', + ...rest, + metadata: JSON.parse(base64Decoded(String(additional_metadata))), + } +} diff --git a/mods/telemetry/tests/fixtures/survey-answer.ts b/mods/telemetry/tests/fixtures/survey-answer.ts new file mode 100644 index 0000000000000000000000000000000000000000..561463d1efa77fce1f3bc2a162dbe46133148460 --- /dev/null +++ b/mods/telemetry/tests/fixtures/survey-answer.ts @@ -0,0 +1,16 @@ +import type { TelemetryLogEntry } from '../../types' + +/** + * A survey answered, as a plugin logs it: a number, a Choice and a + * boolean. + * + * @returns the entry, fresh each call + */ +export const surveyAnswer = (): TelemetryLogEntry => ({ + event: 'survey_answered', + props: { + answer: 2, + page: { value: 'ready', of: ['ready', 'later'] }, + seen: true, + }, +})