Add files using upload-large-folder tool
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- mods/sec-default/.claude-plugin/plugin.json +8 -0
- mods/sec-default/hooks/hooks.json +4 -0
- mods/sec-default/hooks/index.ts +6 -0
- mods/sec-default/hooks/past-users/index.ts +5 -0
- mods/sec-default/hooks/past-users/past-users.ts +23 -0
- mods/sec-default/hooks/policy/create-policy-memo/create-policy-memo.ts +28 -0
- mods/sec-default/hooks/policy/decided-by-policy.ts +15 -0
- mods/sec-default/hooks/policy/has-mcp-allowlist.ts +11 -0
- mods/sec-default/hooks/policy/index.ts +8 -0
- mods/sec-default/hooks/policy/managed-tools-restored/index.ts +4 -0
- mods/sec-default/hooks/policy/managed-tools-restored/managed-tools-restored.ts +33 -0
- mods/sec-default/hooks/policy/policy-memo-ms.ts +7 -0
- mods/sec-default/hooks/policy/source.ts +4 -0
- mods/sec-default/hooks/register.ts +61 -0
- mods/sec-default/hooks/tool-register-refusal/index.ts +3 -0
- mods/sec-default/hooks/tool-register-refusal/tool-register-refusal.ts +6 -0
- mods/sec-default/tests/register.test.ts +309 -0
- mods/telemetry/hooks/entries/checked-props/checked-props.ts +41 -0
- mods/telemetry/hooks/entries/checked-props/index.ts +3 -0
- mods/telemetry/hooks/entries/checked-value/checked-value.ts +74 -0
- mods/telemetry/hooks/entries/checked-value/index.ts +3 -0
- mods/telemetry/hooks/entries/choice-token/choice-token.ts +8 -0
- mods/telemetry/hooks/entries/choice-token/index.ts +3 -0
- mods/telemetry/hooks/entries/choices-limit/choices-limit.ts +4 -0
- mods/telemetry/hooks/entries/choices-limit/index.ts +3 -0
- mods/telemetry/hooks/entries/core-event-prefix/core-event-prefix.ts +5 -0
- mods/telemetry/hooks/entries/core-event-prefix/index.ts +3 -0
- mods/telemetry/hooks/entries/event-prefix/event-prefix.ts +7 -0
- mods/telemetry/hooks/entries/event-prefix/index.ts +3 -0
- mods/telemetry/hooks/entries/feature-prefix/feature-prefix.ts +7 -0
- mods/telemetry/hooks/entries/feature-prefix/index.ts +3 -0
- mods/telemetry/hooks/entries/fields-of/fields-of.ts +16 -0
- mods/telemetry/hooks/entries/fields-of/index.ts +3 -0
- mods/telemetry/hooks/entries/fields/fields.ts +8 -0
- mods/telemetry/hooks/entries/fields/index.ts +3 -0
- mods/telemetry/hooks/entries/is-mark-kind/index.ts +3 -0
- mods/telemetry/hooks/entries/is-mark-kind/is-mark-kind.ts +11 -0
- mods/telemetry/hooks/entries/is-record/index.ts +3 -0
- mods/telemetry/hooks/entries/is-record/is-record.ts +8 -0
- mods/telemetry/hooks/entries/mark-fields-of/index.ts +3 -0
- mods/telemetry/hooks/entries/mark-fields-of/mark-fields-of.ts +28 -0
- mods/telemetry/hooks/entries/mark-kinds/index.ts +3 -0
- mods/telemetry/hooks/entries/mark-kinds/mark-kinds.ts +6 -0
- mods/telemetry/hooks/entries/mark/index.ts +3 -0
- mods/telemetry/hooks/entries/mark/mark.ts +13 -0
- mods/telemetry/hooks/entries/method/index.ts +3 -0
- mods/telemetry/hooks/entries/method/method.ts +4 -0
- mods/telemetry/hooks/entries/prop-limit/index.ts +3 -0
- mods/telemetry/hooks/entries/prop-limit/prop-limit.ts +4 -0
- 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 '.'
|