From 2d27a2d444957e78f3d4d4005c4e39c0fbcc0d83 Mon Sep 17 00:00:00 2001 From: Nihit Kasabwala Date: Fri, 16 Oct 2026 16:58:51 +0000 Subject: [PATCH 01/10] feat: add a telemetry hook to the Claude Code plugin Marketplace plugin installs had no konductor binary and no hooks, so they sent no usage telemetry. The plugin now ships hooks/konductor-telemetry.js, a dependency-free Node port of `konductor __telemetry-hook` for Claude Code: - SessionStart (startup|clear) sends agent_invocation and SubagentStart sends subagent_invocation, only for Konductor's own agents. Plugin agents arrive as `konductor:`; the bare name is reported. - Same envelope, Data fields, session hash and endpoint as the CLI, and the same machine record in ~/.konductor/telemetry.json, created with the CLI's temp file plus link pattern and never overwritten. - Opt-outs: KONDUCTOR_TELEMETRY=off, telemetry.enabled: false in .konductor/config.yml, telemetry_consent: false in the machine record, and a new telemetry_enabled plugin userConfig option. - Prints nothing and always exits 0. Assembly copies the hook and docs/telemetry-schema.json into the plugin tree and renders hooks/hooks.json, with the SubagentStart matcher built from the assembled agents/. make claude-plugin-check runs the new node --test suite. Telemetry is disclosed in the marketplace install guide, the plugin build README, and the root README. Closes #58 --- Makefile | 5 +- README.md | 2 + .../install-claude-plugin-marketplace.md | 24 + generated/claude-plugin/README.md | 28 + scripts/assemble-claude-plugin-branch.sh | 10 + scripts/claude-plugin-telemetry-hook.js | 628 ++++++++++++++++++ scripts/claude-plugin.template.json | 8 + scripts/render-claude-plugin-hooks-json.py | 110 +++ .../claude-plugin-telemetry-hook.test.js | 460 +++++++++++++ .../claude-plugin-telemetry-https-stub.js | 37 ++ ...semble_claude_plugin_branch_mcp_rewrite.py | 24 + 11 files changed, 1335 insertions(+), 1 deletion(-) create mode 100644 scripts/claude-plugin-telemetry-hook.js create mode 100755 scripts/render-claude-plugin-hooks-json.py create mode 100644 tests/scripts/claude-plugin-telemetry-hook.test.js create mode 100644 tests/scripts/claude-plugin-telemetry-https-stub.js diff --git a/Makefile b/Makefile index 2ce30a2a..027965fc 100644 --- a/Makefile +++ b/Makefile @@ -40,7 +40,8 @@ # target's own script first and renders plugin.json/ # .mcp.json at the flat tree's root afterward -- there # is no separate repo-root plugin layout. -# claude-plugin-check PR-time smoke test: assembles the flat branch tree +# claude-plugin-check PR-time smoke test: runs the plugin telemetry hook's +# node tests, assembles the flat branch tree # (scripts/assemble-claude-plugin-branch.sh, which # calls claude-plugin's own generation first) into a # scratch directory, then validates it (--strict) when @@ -187,6 +188,8 @@ claude-plugin: # (see assemble-claude-plugin-branch.sh and claude-plugin's own comment # above). claude-plugin-check: + @echo "=== [claude-plugin-check] Running the plugin telemetry hook tests ===" + node --test tests/scripts/claude-plugin-telemetry-hook.test.js $(MAKE) -C cli build TARGET=$(TARGET) @test -x "$(KONDUCTOR_BIN)" || { echo "error: $(KONDUCTOR_BIN) not found; run 'make -C cli build' first" >&2; exit 1; } @validated=1; \ diff --git a/README.md b/README.md index b83f7e24..22af7df7 100644 --- a/README.md +++ b/README.md @@ -364,6 +364,8 @@ This solution sends operational metrics to AWS (the "Data") about the use of thi To opt out, pass `--no-telemetry` to `konductor install`/`konductor update`, set `telemetry.enabled: false` in `.konductor/config.yml`, or set `KONDUCTOR_TELEMETRY=off` in your environment. +**Claude Code plugin.** The marketplace plugin sends `agent_invocation` and `subagent_invocation` events in the same format through a plugin hook, and shares the machine ID in `$HOME/.konductor/telemetry.json` with the CLI. Turn it off with the plugin's `telemetry_enabled` option, or any of the opt-outs above other than `--no-telemetry`. See [Install via the Claude Code plugin marketplace](docs/user-guide/tasks/install-claude-plugin-marketplace.md#telemetry). + --- Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. diff --git a/docs/user-guide/tasks/install-claude-plugin-marketplace.md b/docs/user-guide/tasks/install-claude-plugin-marketplace.md index 557ccdff..62ff725a 100644 --- a/docs/user-guide/tasks/install-claude-plugin-marketplace.md +++ b/docs/user-guide/tasks/install-claude-plugin-marketplace.md @@ -92,6 +92,30 @@ npx playwright install chromium Add `--scope user` to make that server available across projects. +## Telemetry + +The plugin sends usage telemetry to AWS by default. A hook runs when a +session starts with a Konductor agent and when a Konductor agent is +delegated to. Each event carries the agent name, the delegating Konductor +agent's name when Claude Code exposes it, the plugin version, and a hashed +session ID. Your own agents and Claude Code's built-in agents are never +reported. Prompts, file paths, and tool inputs are never sent. + +Events also carry the machine ID stored in `~/.konductor/telemetry.json`. +The plugin creates that file if it is missing. The Konductor CLI uses the +same file, so plugin and CLI events from one machine share one ID. + +To opt out, do any one of these: + +- Turn off the plugin's `telemetry_enabled` option in `/config`. +- Set `KONDUCTOR_TELEMETRY=off` in your environment. +- Set `telemetry.enabled: false` in the project's `.konductor/config.yml`. +- Set `"telemetry_consent": false` in `~/.konductor/telemetry.json`. This + also stops the CLI from reporting on this machine. + +See [Data Collection](../../../README.md#data-collection) for the full +details and the event schema. + ## Related - [Install for Claude Code](install-claude-code.md) diff --git a/generated/claude-plugin/README.md b/generated/claude-plugin/README.md index 5b9d194b..6cf199f7 100644 --- a/generated/claude-plugin/README.md +++ b/generated/claude-plugin/README.md @@ -68,6 +68,9 @@ The published branch contains: .mcp.json agents/ skills/ +hooks/hooks.json +hooks/konductor-telemetry.js +docs/telemetry-schema.json README.md LICENSE.txt ``` @@ -111,6 +114,31 @@ mcp__plugin_konductor_aws-mcp__* Bring-your-own servers, including `playwright-mcp`, keep their bare grants and must be configured by the user. +## Telemetry + +The plugin ships a telemetry hook. It is on by default and has no npm +dependencies; Claude Code runs it with `node`. + +- `scripts/claude-plugin-telemetry-hook.js` is copied to + `hooks/konductor-telemetry.js`. It reports `agent_invocation` on + `SessionStart` (`startup|clear`) and `subagent_invocation` on + `SubagentStart`, only for Konductor's own agents, in the same format as + `konductor __telemetry-hook` (`docs/telemetry-schema.json`, also copied + into the tree). It prints nothing and always exits 0. +- `scripts/render-claude-plugin-hooks-json.py` writes `hooks/hooks.json`. + The `SubagentStart` matcher lists every file in the assembled `agents/`, + in both the `konductor:` and bare forms. +- The machine ID is shared with the CLI through `~/.konductor/telemetry.json`. + +Users opt out with the plugin's `telemetry_enabled` option, +`KONDUCTOR_TELEMETRY=off`, `telemetry.enabled: false` in +`.konductor/config.yml`, or `"telemetry_consent": false` in +`~/.konductor/telemetry.json`. See the +[install guide](../../docs/user-guide/tasks/install-claude-plugin-marketplace.md#telemetry) +and the root README's [Data Collection](../../README.md#data-collection) +section. Tests: `node --test tests/scripts/claude-plugin-telemetry-hook.test.js`, +also run by `make claude-plugin-check`. + ## Kiro CLI Kiro CLI renders `clientConfig.kiroCli.mcpServers`, not an agent's diff --git a/scripts/assemble-claude-plugin-branch.sh b/scripts/assemble-claude-plugin-branch.sh index 0e82115e..7cd239fb 100755 --- a/scripts/assemble-claude-plugin-branch.sh +++ b/scripts/assemble-claude-plugin-branch.sh @@ -77,4 +77,14 @@ python3 "$REPO_ROOT/scripts/rewrite-claude-plugin-mcp-tool-names.py" \ --plugin-json "$OUT/.claude-plugin/plugin.json" \ --bundled-mcp-servers "$BUNDLED_MCP_SERVERS" +# Telemetry hook. hooks.json is generated from the assembled agents/ so the +# SubagentStart matcher covers every agent shipped. +mkdir -p "$OUT/hooks" "$OUT/docs" +cp "$REPO_ROOT/scripts/claude-plugin-telemetry-hook.js" "$OUT/hooks/konductor-telemetry.js" +cp "$REPO_ROOT/docs/telemetry-schema.json" "$OUT/docs/telemetry-schema.json" +python3 "$REPO_ROOT/scripts/render-claude-plugin-hooks-json.py" \ + --agents-dir "$OUT/agents" \ + --plugin-json "$OUT/.claude-plugin/plugin.json" \ + --output "$OUT/hooks/hooks.json" + echo "=== [assemble-claude-plugin] Done: flat plugin tree at $OUT ===" diff --git a/scripts/claude-plugin-telemetry-hook.js b/scripts/claude-plugin-telemetry-hook.js new file mode 100644 index 00000000..9e0a1c71 --- /dev/null +++ b/scripts/claude-plugin-telemetry-hook.js @@ -0,0 +1,628 @@ +#!/usr/bin/env node +// SPDX-License-Identifier: Apache-2.0 +// +// Claude Code plugin telemetry hook, copied to hooks/konductor-telemetry.js +// in the published plugin tree. The plugin ships no konductor binary, so +// this is a dependency-free port of `konductor __telemetry-hook` for the two +// Claude Code events it handles: +// +// node konductor-telemetry.js agent-invocation (SessionStart) +// node konductor-telemetry.js subagent-invocation (SubagentStart) +// +// It reads the hook payload from stdin, prints nothing, and always exits 0. +// SessionStart stdout becomes model context, so any output would be a bug. +// +// Wire format and rules mirror cli/konductor-rs/src/cli/telemetry_hook.rs, +// cli/konductor-rs/src/cli/telemetry/{report,envelope}.rs and +// shared/konductor-telemetry/src/identity.rs, so plugin and CLI events line +// up and share one machine identity in ~/.konductor/telemetry.json. + +'use strict'; + +const crypto = require('node:crypto'); +const fs = require('node:fs'); +const https = require('node:https'); +const net = require('node:net'); +const os = require('node:os'); +const path = require('node:path'); + +const SOLUTION_ID = 'SO0370'; +const DEFAULT_TELEMETRY_ENDPOINT = 'https://metrics.awssolutionsbuilder.com/generic'; +const NIL_UUID_SENTINEL = '0'.repeat(64); +const INSTANCE_SCHEMA_VERSION = 1; +const MAX_HOOK_PAYLOAD_BYTES = 64 * 1024; +const SEND_TIMEOUT_MS = 3000; +const AGENT_INVOCATION = 'agent-invocation'; +const SUBAGENT_INVOCATION = 'subagent-invocation'; +const DEFAULT_PLUGIN_NAME = 'konductor'; + +const PLUGIN_ROOT = path.resolve(__dirname, '..'); + +function sha256Hex(text) { + return crypto.createHash('sha256').update(text, 'utf8').digest('hex'); +} + +function isValidUuidShape(value) { + return typeof value === 'string' && /^[a-f0-9]{64}$/.test(value); +} + +function nonEmptyString(value) { + return typeof value === 'string' && value !== '' ? value : null; +} + +function isoMillis(date) { + return date.toISOString(); +} + +// The outer envelope's "YYYY-MM-DD HH:MM:SS.f" shape (tenths of a second). +function wireTimestamp(date) { + const iso = date.toISOString(); + return `${iso.slice(0, 10)} ${iso.slice(11, 19)}.${iso.slice(20, 21)}`; +} + +function epochNanos() { + return BigInt(Date.now()) * 1000000n + (process.hrtime.bigint() % 1000000n); +} + +function hashPresentSessionId(uuid, rawSessionId) { + const raw = nonEmptyString(rawSessionId); + return raw === null ? null : sha256Hex(`${uuid}${raw}`); +} + +function buildEventBody({ + eventType, + targetName, + uuid, + version, + sessionId = null, + parentSessionId = null, + parentAgentName = null, + now = new Date(), +}) { + return { + Solution: SOLUTION_ID, + Version: version, + UUID: uuid, + TimeStamp: wireTimestamp(now), + Data: { + // Rust passes the nil sentinel as eventId entropy for agent events. + eventId: sha256Hex(`${NIL_UUID_SENTINEL}-${epochNanos()}-${process.pid}`), + eventType, + targetName, + sessionId: hashPresentSessionId(uuid, sessionId), + parentSessionId: hashPresentSessionId(uuid, parentSessionId), + parentAgentName: nonEmptyString(parentAgentName), + errorCode: null, + harness: null, + agentVersion: null, + TimeStamp: isoMillis(now), + }, + }; +} + +// ── Consent ────────────────────────────────────────────────────────────── + +function telemetryOffByEnv(env) { + return env.KONDUCTOR_TELEMETRY === 'off'; +} + +// Minimal read of `telemetry.enabled` from .konductor/config.yml: a block +// mapping under a top-level `telemetry:` key, or a one-line flow mapping. +function configDisablesTelemetry(text) { + const isFalse = (value) => ['false', 'False', 'FALSE'].includes(value); + let inSection = false; + let childIndent = null; + for (const rawLine of text.split(/\r?\n/)) { + const line = rawLine.replace(/(^|\s)#.*$/, '').replace(/\s+$/, ''); + if (line.trim() === '' || line === '---') { + continue; + } + const indent = line.length - line.trimStart().length; + if (indent === 0) { + const top = /^telemetry\s*:\s*(.*)$/.exec(line); + inSection = top !== null; + childIndent = null; + if (top && top[1].startsWith('{')) { + const flow = /[{,]\s*enabled\s*:\s*([^,}\s]+)\s*[,}]/.exec(top[1]); + if (flow && isFalse(flow[1])) { + return true; + } + } + continue; + } + if (!inSection) { + continue; + } + if (childIndent === null) { + childIndent = indent; + } + if (indent !== childIndent) { + continue; + } + const entry = /^\s+enabled\s*:\s*(\S+)$/.exec(line); + if (entry && isFalse(entry[1])) { + return true; + } + } + return false; +} + +function telemetryOffByConfig(cwd) { + try { + const text = fs.readFileSync(path.join(cwd, '.konductor', 'config.yml'), 'utf8'); + return configDisablesTelemetry(text); + } catch { + return false; + } +} + +// Plugin userConfig `telemetry_enabled`, exported by Claude Code to hooks. +function telemetryOffByPluginOption(env) { + const value = env.CLAUDE_PLUGIN_OPTION_TELEMETRY_ENABLED; + if (typeof value !== 'string') { + return false; + } + return ['false', '0', 'off', 'no'].includes(value.trim().toLowerCase()); +} + +// ── Machine identity (~/.konductor/telemetry.json) ─────────────────────── + +function homeDir(env) { + const home = nonEmptyString(env.HOME); + if (home !== null) { + return home; + } + // The CLI anchors on $HOME only; Windows rarely sets it. + return process.platform === 'win32' ? nonEmptyString(os.homedir()) : null; +} + +function instancePath(home) { + return path.join(home, '.konductor', 'telemetry.json'); +} + +// Same outcomes as identity.rs `read_instance_raw`: ok, newer, or absent. +function readInstanceRaw(home) { + let value; + try { + value = JSON.parse(fs.readFileSync(instancePath(home), 'utf8')); + } catch { + return { kind: 'absent' }; + } + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + return { kind: 'absent' }; + } + const schemaVersion = value.schema_version; + if (!Number.isInteger(schemaVersion) || schemaVersion < 0) { + return { kind: 'absent' }; + } + if (schemaVersion > INSTANCE_SCHEMA_VERSION) { + return { kind: 'newer', uuid: isValidUuidShape(value.UUID) ? value.UUID : null }; + } + if ( + schemaVersion !== INSTANCE_SCHEMA_VERSION || + !isValidUuidShape(value.UUID) || + typeof value.created_at !== 'string' || + typeof value.telemetry_consent !== 'boolean' + ) { + return { kind: 'absent' }; + } + return { + kind: 'ok', + record: { + schema_version: schemaVersion, + UUID: value.UUID, + created_at: value.created_at, + telemetry_consent: value.telemetry_consent, + }, + }; +} + +function failClosed(uuid = NIL_UUID_SENTINEL) { + return { UUID: uuid, telemetry_consent: false }; +} + +function fromReadOutcome(outcome) { + if (outcome.kind === 'ok') { + return outcome.record; + } + if (outcome.kind === 'newer') { + return failClosed(outcome.uuid || NIL_UUID_SENTINEL); + } + return null; +} + +function createPrivateDir(dir) { + fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); + if (process.platform === 'win32') { + return; + } + const stat = fs.lstatSync(dir); + if (stat.isSymbolicLink() || !stat.isDirectory()) { + throw new Error('not a real directory'); + } + if (typeof process.getuid === 'function' && stat.uid !== process.getuid()) { + throw new Error('directory owned by another user'); + } + fs.chmodSync(dir, 0o700); +} + +let tempCounter = 0; + +// Mirrors identity.rs `ensure_instance`: publish with link(2) so a racing +// writer can't be overwritten, and never repair or replace an existing file. +// An unreadable record fails closed (nil UUID, no consent) for this call. +function ensureInstance(home, now = new Date()) { + const existing = fromReadOutcome(readInstanceRaw(home)); + if (existing !== null) { + return existing; + } + const dir = path.join(home, '.konductor'); + try { + createPrivateDir(dir); + } catch { + return failClosed(); + } + const record = { + schema_version: INSTANCE_SCHEMA_VERSION, + UUID: crypto.randomBytes(32).toString('hex'), + created_at: isoMillis(now), + telemetry_consent: true, + }; + const tmp = path.join(dir, `telemetry.json.tmp-${process.pid}-${tempCounter++}`); + try { + try { + fs.unlinkSync(tmp); + } catch { + // No leftover temp file. + } + const flags = + fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | (fs.constants.O_NOFOLLOW || 0); + const fd = fs.openSync(tmp, flags, 0o600); + try { + if (process.platform !== 'win32') { + fs.fchmodSync(fd, 0o600); + } + fs.writeSync(fd, `${JSON.stringify(record, null, 2)}\n`); + } finally { + fs.closeSync(fd); + } + try { + fs.linkSync(tmp, instancePath(home)); + return record; + } catch (err) { + if (err && err.code === 'EEXIST') { + return fromReadOutcome(readInstanceRaw(home)) || failClosed(); + } + return failClosed(); + } + } catch { + return failClosed(); + } finally { + try { + fs.unlinkSync(tmp); + } catch { + // Already gone. + } + } +} + +// ── Endpoint allowlist (same rules as konductor-telemetry-report.sh) ───── + +function parseIpv4(text) { + const parts = text.split('.'); + if (parts.length !== 4 || !parts.every((p) => /^\d{1,3}$/.test(p) && Number(p) <= 255)) { + return null; + } + return parts.map(Number); +} + +function parseIpv6(text) { + let head = text; + let tail = []; + const lastColon = text.lastIndexOf(':'); + if (text.slice(lastColon + 1).includes('.')) { + const v4 = parseIpv4(text.slice(lastColon + 1)); + if (v4 === null) { + return null; + } + head = text.slice(0, lastColon + 1); + if (head.endsWith(':') && !head.endsWith('::')) { + head = head.slice(0, -1); + } + tail = [(v4[0] << 8) | v4[1], (v4[2] << 8) | v4[3]]; + } + const hextets = (part) => (part === '' ? [] : part.split(':').map((h) => parseInt(h, 16))); + const halves = head.split('::'); + if (halves.length > 2) { + return null; + } + const left = hextets(halves[0]); + const right = halves.length === 2 ? hextets(halves[1]) : []; + const fill = 8 - left.length - right.length - tail.length; + if (fill < 0 || (halves.length === 1 && fill !== 0)) { + return null; + } + const segments = [...left, ...new Array(fill).fill(0), ...right, ...tail]; + return segments.every((s) => Number.isInteger(s) && s >= 0 && s <= 0xffff) ? segments : null; +} + +function isDisallowedIpv4([a, b]) { + return ( + a === 0 || + a === 127 || + a === 10 || + (a === 169 && b === 254) || + (a === 172 && b >= 16 && b <= 31) || + (a === 192 && b === 168) || + (a === 100 && (b & 0xc0) === 0x40) + ); +} + +function embeddedIpv4(hi, lo) { + return [hi >> 8, hi & 0xff, lo >> 8, lo & 0xff]; +} + +function isDisallowedIpv6(s) { + const zeroThrough = (n) => s.slice(0, n).every((x) => x === 0); + if (zeroThrough(5) && s[5] === 0xffff) { + return isDisallowedIpv4(embeddedIpv4(s[6], s[7])); + } + if (zeroThrough(6)) { + // `::` and `::1`; anything else here is the IPv4-compatible form. + if (s[6] === 0 && (s[7] === 0 || s[7] === 1)) { + return true; + } + return isDisallowedIpv4(embeddedIpv4(s[6], s[7])); + } + if (s[0] === 0x2002) { + return isDisallowedIpv4(embeddedIpv4(s[1], s[2])); + } + if (s[0] === 0x64 && s[1] === 0xff9b && s.slice(2, 6).every((x) => x === 0)) { + return isDisallowedIpv4(embeddedIpv4(s[6], s[7])); + } + return (s[0] & 0xffc0) === 0xfe80 || (s[0] & 0xfe00) === 0xfc00; +} + +function isDisallowedHost(host) { + if (host.toLowerCase() === 'localhost') { + return true; + } + if (net.isIPv4(host)) { + return isDisallowedIpv4(parseIpv4(host)); + } + if (net.isIPv6(host)) { + const segments = parseIpv6(host); + return segments === null || isDisallowedIpv6(segments); + } + return false; +} + +// No DNS resolution or pinning here, unlike the CLI: a hostname that +// resolves to a private address is not caught. +function resolveEndpoint(env) { + const candidate = nonEmptyString(env.KONDUCTOR_METRICS_ENDPOINT) || DEFAULT_TELEMETRY_ENDPOINT; + if (!candidate.startsWith('https://')) { + return null; + } + let url; + try { + url = new URL(candidate); + } catch { + return null; + } + if (url.protocol !== 'https:' || url.hostname === '') { + return null; + } + if (!nonEmptyString(env.KONDUCTOR_TELEMETRY_ALLOW_LOCAL_ENDPOINT)) { + const host = url.hostname.replace(/^\[(.*)\]$/, '$1'); + if (isDisallowedHost(host)) { + return null; + } + } + return url; +} + +// ── Agent names ────────────────────────────────────────────────────────── + +function readPluginManifest(pluginRoot) { + try { + const manifest = JSON.parse( + fs.readFileSync(path.join(pluginRoot, '.claude-plugin', 'plugin.json'), 'utf8'), + ); + const version = nonEmptyString(manifest.version); + if (version === null) { + return null; + } + return { name: nonEmptyString(manifest.name) || DEFAULT_PLUGIN_NAME, version }; + } catch { + return null; + } +} + +function konductorAgentNames(pluginRoot) { + try { + return new Set( + fs + .readdirSync(path.join(pluginRoot, 'agents')) + .filter((file) => file.endsWith('.md') && !file.startsWith('_')) + .map((file) => file.slice(0, -'.md'.length)), + ); + } catch { + return new Set(); + } +} + +// Claude Code reports plugin agents as `:`; standalone +// installs use the bare name. Returns the bare name, or null when the +// agent isn't one of this plugin's. +function konductorAgentName(raw, pluginName, agents) { + const value = nonEmptyString(raw); + if (value === null) { + return null; + } + const prefix = `${pluginName}:`; + const bare = value.startsWith(prefix) ? value.slice(prefix.length) : value; + return agents.has(bare) ? bare : null; +} + +// Mirrors the Claude rows of telemetry_hook.rs `resolve_invocations`. +function resolveInvocation(eventType, payload, env, pluginName, agents) { + const sessionId = nonEmptyString(payload.session_id) || nonEmptyString(payload.sessionId); + const agentType = nonEmptyString(payload.agent_type) || nonEmptyString(payload.agent_name); + const agent = konductorAgentName(agentType, pluginName, agents); + if (agent === null) { + return null; + } + if (eventType === AGENT_INVOCATION) { + return { eventType: 'agent_invocation', targetName: agent, sessionId, parentSessionId: null, parentAgentName: null }; + } + if (eventType === SUBAGENT_INVOCATION) { + return { + eventType: 'subagent_invocation', + targetName: agent, + sessionId, + parentSessionId: sessionId, + parentAgentName: konductorAgentName(env.CLAUDE_CODE_AGENT, pluginName, agents), + }; + } + return null; +} + +// ── Transport ──────────────────────────────────────────────────────────── + +function send(url, body) { + return new Promise((resolve) => { + let request; + const done = () => { + clearTimeout(timer); + resolve(); + }; + const timer = setTimeout(() => { + if (request) { + request.destroy(); + } + done(); + }, SEND_TIMEOUT_MS); + try { + const payload = Buffer.from(JSON.stringify(body), 'utf8'); + request = https.request(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Content-Length': payload.length }, + timeout: SEND_TIMEOUT_MS, + }); + request.on('response', (response) => { + response.resume(); + response.on('end', done); + response.on('error', done); + }); + request.on('timeout', () => request.destroy()); + request.on('error', done); + request.on('close', done); + request.end(payload); + } catch { + done(); + } + }); +} + +function readStdinCapped() { + return new Promise((resolve) => { + const chunks = []; + let size = 0; + process.stdin.on('data', (chunk) => { + size += chunk.length; + if (size > MAX_HOOK_PAYLOAD_BYTES) { + process.stdin.destroy(); + resolve(null); + return; + } + chunks.push(chunk); + }); + process.stdin.on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))); + process.stdin.on('error', () => resolve(null)); + }); +} + +async function run(argv, env) { + const eventType = argv[0]; + if (eventType !== AGENT_INVOCATION && eventType !== SUBAGENT_INVOCATION) { + return; + } + const text = await readStdinCapped(); + if (text === null) { + return; + } + let payload; + try { + payload = JSON.parse(text); + } catch { + return; + } + if (payload === null || typeof payload !== 'object' || Array.isArray(payload)) { + return; + } + + if (telemetryOffByEnv(env)) { + return; + } + const cwd = nonEmptyString(payload.cwd) || process.cwd(); + if (telemetryOffByConfig(cwd) || telemetryOffByPluginOption(env)) { + return; + } + + const manifest = readPluginManifest(PLUGIN_ROOT); + if (manifest === null) { + return; + } + const invocation = resolveInvocation( + eventType, + payload, + env, + manifest.name, + konductorAgentNames(PLUGIN_ROOT), + ); + if (invocation === null) { + return; + } + const endpoint = resolveEndpoint(env); + if (endpoint === null) { + return; + } + const home = homeDir(env); + if (home === null) { + return; + } + const record = ensureInstance(home); + if (!record.telemetry_consent) { + return; + } + + await send(endpoint, buildEventBody({ ...invocation, uuid: record.UUID, version: manifest.version })); +} + +function main() { + const quietExit = () => process.exit(0); + process.on('uncaughtException', quietExit); + process.on('unhandledRejection', quietExit); + // Backstop in case stdin never closes. + setTimeout(quietExit, SEND_TIMEOUT_MS + 2000).unref(); + run(process.argv.slice(2), process.env).then(quietExit, quietExit); +} + +if (require.main === module) { + main(); +} + +module.exports = { + DEFAULT_TELEMETRY_ENDPOINT, + NIL_UUID_SENTINEL, + SOLUTION_ID, + buildEventBody, + configDisablesTelemetry, + ensureInstance, + isDisallowedHost, + konductorAgentName, + readInstanceRaw, + resolveEndpoint, + resolveInvocation, +}; diff --git a/scripts/claude-plugin.template.json b/scripts/claude-plugin.template.json index 1aaab28d..a91357da 100644 --- a/scripts/claude-plugin.template.json +++ b/scripts/claude-plugin.template.json @@ -28,6 +28,14 @@ "skills": [ "./generated/claude-plugin/skills/" ], + "userConfig": { + "telemetry_enabled": { + "type": "boolean", + "title": "Send usage telemetry to AWS", + "description": "Report which Konductor agents run. Each event carries the agent name, the plugin version, a hashed session ID, and the machine ID in ~/.konductor/telemetry.json, which the Konductor CLI also uses. See Data Collection in the README.", + "default": true + } + }, "metadata": { "sourceRepo": "https://github.com/aws-solutions/konductor", "generatedFrom": "agents/*.agent-spec.json, skills/*/SKILL.md, agent-sops/*.sop.md", diff --git a/scripts/render-claude-plugin-hooks-json.py b/scripts/render-claude-plugin-hooks-json.py new file mode 100755 index 00000000..9c446d04 --- /dev/null +++ b/scripts/render-claude-plugin-hooks-json.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: Apache-2.0 +"""Render the plugin's hooks/hooks.json for the telemetry hook. + +SessionStart (startup|clear) reports agent_invocation and SubagentStart +reports subagent_invocation, the same events the CLI wires into Claude +Code settings. The SubagentStart matcher is built from the assembled +agents/*.md, so a new agent can't be left out. Claude Code names plugin +agents `:`; the matcher also accepts the bare name. + +Called by scripts/assemble-claude-plugin-branch.sh. +""" +import argparse +import json +import re +import sys +from pathlib import Path + +HOOK_SCRIPT = "hooks/konductor-telemetry.js" + + +def frontmatter_name(agent_file: Path): + lines = agent_file.read_text(encoding="utf-8").splitlines() + if not lines or lines[0].strip() != "---": + return None + for line in lines[1:]: + if line.strip() == "---": + return None + match = re.match(r"^name:\s*['\"]?([^'\"\s]+)['\"]?\s*$", line) + if match: + return match.group(1) + return None + + +def agent_names(agents_dir: Path): + names = [] + for agent_file in sorted(agents_dir.glob("*.md")): + if agent_file.name.startswith("_"): + continue + name = frontmatter_name(agent_file) + # The hook filters on file names, so they must match what Claude + # Code reports as agent_type. + if name != agent_file.stem: + raise ValueError( + f"{agent_file.name}: frontmatter name {name!r} does not match the file name" + ) + names.append(name) + return names + + +def command(event_type: str) -> dict: + return { + "type": "command", + "command": f'node "${{CLAUDE_PLUGIN_ROOT}}/{HOOK_SCRIPT}" {event_type}', + "async": True, + } + + +def regex_escape(text: str) -> str: + # Same character set as the CLI's installed_agents_matcher, so a + # JavaScript regex compiled with or without the `u` flag accepts it. + return "".join("\\" + c if c in "\\^$.|?*+()[]{}/" else c for c in text) + + +def render(plugin_name: str, names) -> dict: + alternatives = "|".join(regex_escape(name) for name in sorted(set(names))) + return { + "hooks": { + "SessionStart": [ + {"matcher": "startup|clear", "hooks": [command("agent-invocation")]} + ], + "SubagentStart": [ + { + "matcher": f"^({regex_escape(plugin_name)}:)?({alternatives})$", + "hooks": [command("subagent-invocation")], + } + ], + } + } + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--agents-dir", required=True, type=Path) + parser.add_argument("--plugin-json", required=True, type=Path) + parser.add_argument("--output", required=True, type=Path) + args = parser.parse_args() + + plugin_name = json.loads(args.plugin_json.read_text(encoding="utf-8")).get("name") + if not plugin_name: + print(f"error: no plugin name in {args.plugin_json}", file=sys.stderr) + return 1 + try: + names = agent_names(args.agents_dir) + except ValueError as err: + print(f"error: {err}", file=sys.stderr) + return 1 + if not names: + print(f"error: no agent .md files found under {args.agents_dir}", file=sys.stderr) + return 1 + + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text( + json.dumps(render(plugin_name, names), indent=2) + "\n", encoding="utf-8" + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/scripts/claude-plugin-telemetry-hook.test.js b/tests/scripts/claude-plugin-telemetry-hook.test.js new file mode 100644 index 00000000..3f7ec50e --- /dev/null +++ b/tests/scripts/claude-plugin-telemetry-hook.test.js @@ -0,0 +1,460 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// Tests for scripts/claude-plugin-telemetry-hook.js, the Claude Code plugin's +// telemetry hook. Each end-to-end case runs the hook from a scratch plugin +// tree with a scratch HOME, and preloads claude-plugin-telemetry-https-stub.js +// so nothing reaches the real endpoint. +// +// Run: node --test tests/scripts/claude-plugin-telemetry-hook.test.js +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { spawnSync } = require('node:child_process'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +const REPO_ROOT = path.join(__dirname, '..', '..'); +const HOOK_SOURCE = path.join(REPO_ROOT, 'scripts', 'claude-plugin-telemetry-hook.js'); +const HOOKS_RENDERER = path.join(REPO_ROOT, 'scripts', 'render-claude-plugin-hooks-json.py'); +const SCHEMA_PATH = path.join(REPO_ROOT, 'docs', 'telemetry-schema.json'); +const REPORT_RS = path.join(REPO_ROOT, 'cli', 'konductor-rs', 'src', 'cli', 'telemetry', 'report.rs'); +const STUB = path.join(__dirname, 'claude-plugin-telemetry-https-stub.js'); + +const hook = require(HOOK_SOURCE); + +const PLUGIN_VERSION = '9.8.7'; +const AGENTS = ['konductor', 'k-architect', 'k-developer']; +const RAW_SESSION_ID = 'bd3b7f9b-1111-2222-3333-444455556666'; +const HEX64 = /^[a-f0-9]{64}$/; + +function scratchDir(t, label) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), `konductor-plugin-telemetry-${label}-`)); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + return dir; +} + +function makePluginTree(t) { + const root = scratchDir(t, 'plugin'); + fs.mkdirSync(path.join(root, '.claude-plugin')); + fs.mkdirSync(path.join(root, 'agents')); + fs.mkdirSync(path.join(root, 'hooks')); + fs.writeFileSync( + path.join(root, '.claude-plugin', 'plugin.json'), + JSON.stringify({ name: 'konductor', version: PLUGIN_VERSION }), + ); + for (const agent of AGENTS) { + fs.writeFileSync(path.join(root, 'agents', `${agent}.md`), `---\nname: ${agent}\n---\nbody\n`); + } + fs.copyFileSync(HOOK_SOURCE, path.join(root, 'hooks', 'konductor-telemetry.js')); + return root; +} + +// Runs the hook once. `env` is the whole environment apart from PATH, HOME +// and the stub's output file, so the developer's own settings never leak in. +function runHook(t, { eventType, payload, env = {}, home, stdin }) { + const plugin = makePluginTree(t); + const homeDir = home || scratchDir(t, 'home'); + const project = scratchDir(t, 'project'); + const stubOut = path.join(scratchDir(t, 'stub'), 'requests.jsonl'); + const input = + stdin !== undefined ? stdin : JSON.stringify({ cwd: project, session_id: RAW_SESSION_ID, ...payload }); + const result = spawnSync( + process.execPath, + ['--require', STUB, path.join(plugin, 'hooks', 'konductor-telemetry.js'), ...(eventType ? [eventType] : [])], + { + input, + cwd: project, + env: { PATH: process.env.PATH, HOME: homeDir, TELEMETRY_STUB_OUT: stubOut, ...env }, + encoding: 'utf8', + timeout: 15000, + }, + ); + const requests = fs.existsSync(stubOut) + ? fs + .readFileSync(stubOut, 'utf8') + .trim() + .split('\n') + .map((line) => JSON.parse(line)) + : []; + return { result, requests, home: homeDir, project }; +} + +function recordPath(home) { + return path.join(home, '.konductor', 'telemetry.json'); +} + +function writeRecord(home, record) { + fs.mkdirSync(path.join(home, '.konductor'), { recursive: true }); + const text = typeof record === 'string' ? record : `${JSON.stringify(record, null, 2)}\n`; + fs.writeFileSync(recordPath(home), text); + return text; +} + +const VALID_UUID = 'ab'.repeat(32); + +function assertSilent(result) { + assert.equal(result.status, 0); + assert.equal(result.stdout, ''); + assert.equal(result.stderr, ''); +} + +// ── Contract with the schema and the CLI ───────────────────────────────── + +test('body from the exported builder has every schema-required field', () => { + const schema = JSON.parse(fs.readFileSync(SCHEMA_PATH, 'utf8')); + const sessionPattern = new RegExp(schema.properties.sessionId.pattern); + for (const eventType of ['agent_invocation', 'subagent_invocation']) { + const body = hook.buildEventBody({ + eventType, + targetName: 'k-architect', + uuid: VALID_UUID, + version: PLUGIN_VERSION, + sessionId: RAW_SESSION_ID, + parentSessionId: eventType === 'subagent_invocation' ? RAW_SESSION_ID : null, + parentAgentName: eventType === 'subagent_invocation' ? 'konductor' : null, + }); + assert.deepEqual(Object.keys(body), ['Solution', 'Version', 'UUID', 'TimeStamp', 'Data']); + for (const key of schema.required) { + assert.ok(Object.hasOwn(body.Data, key), `${eventType} is missing ${key}`); + } + assert.match(body.Data.sessionId, sessionPattern); + assert.match(body.Data.eventId, new RegExp(schema.properties.eventId.pattern)); + assert.equal(body.Data.harness, null); + assert.equal(body.Data.errorCode, null); + assert.equal(body.Data.agentVersion, null); + assert.match(body.TimeStamp, /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d$/); + assert.match(body.Data.TimeStamp, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/); + } +}); + +test('session ids are hashed as sha256(UUID + raw id), and absent ids stay null', () => { + const crypto = require('node:crypto'); + const body = hook.buildEventBody({ + eventType: 'agent_invocation', + targetName: 'konductor', + uuid: VALID_UUID, + version: PLUGIN_VERSION, + sessionId: RAW_SESSION_ID, + }); + const expected = crypto.createHash('sha256').update(`${VALID_UUID}${RAW_SESSION_ID}`).digest('hex'); + assert.equal(body.Data.sessionId, expected); + assert.equal(body.Data.parentSessionId, null); + assert.equal(body.Data.parentAgentName, null); + const empty = hook.buildEventBody({ eventType: 'agent_invocation', targetName: 'x', uuid: VALID_UUID, version: '1', sessionId: '' }); + assert.equal(empty.Data.sessionId, null); +}); + +test('endpoint and solution id match the CLI source', () => { + const source = fs.readFileSync(REPORT_RS, 'utf8'); + const endpoint = /const DEFAULT_TELEMETRY_ENDPOINT: &str = "([^"]+)";/.exec(source); + const solution = /const SOLUTION_ID: &str = "([^"]+)";/.exec(source); + assert.ok(endpoint && solution, 'constants not found in report.rs'); + assert.equal(hook.DEFAULT_TELEMETRY_ENDPOINT, endpoint[1]); + assert.equal(hook.SOLUTION_ID, solution[1]); +}); + +// ── End to end ─────────────────────────────────────────────────────────── + +test('SessionStart for a plugin agent posts one agent_invocation', (t) => { + const { result, requests, home } = runHook(t, { + eventType: 'agent-invocation', + payload: { hook_event_name: 'SessionStart', source: 'startup', agent_type: 'konductor:konductor' }, + }); + assertSilent(result); + assert.equal(requests.length, 1); + const [request] = requests; + assert.equal(request.url, hook.DEFAULT_TELEMETRY_ENDPOINT); + assert.equal(request.method, 'POST'); + assert.equal(request.headers['Content-Type'], 'application/json'); + const body = JSON.parse(request.body); + const record = JSON.parse(fs.readFileSync(recordPath(home), 'utf8')); + assert.equal(body.Solution, 'SO0370'); + assert.equal(body.Version, PLUGIN_VERSION); + assert.equal(body.UUID, record.UUID); + assert.equal(body.Data.eventType, 'agent_invocation'); + assert.equal(body.Data.targetName, 'konductor'); + assert.match(body.Data.sessionId, HEX64); + assert.equal(body.Data.parentSessionId, null); +}); + +test('a bare agent name is reported the same way', (t) => { + const { requests } = runHook(t, { eventType: 'agent-invocation', payload: { agent_type: 'k-architect' } }); + assert.equal(requests.length, 1); + assert.equal(JSON.parse(requests[0].body).Data.targetName, 'k-architect'); +}); + +test('SubagentStart reports the target and a Konductor parent from CLAUDE_CODE_AGENT', (t) => { + const { requests } = runHook(t, { + eventType: 'subagent-invocation', + payload: { hook_event_name: 'SubagentStart', agent_id: 'a36b', agent_type: 'konductor:k-developer' }, + env: { CLAUDE_CODE_AGENT: 'konductor:konductor' }, + }); + assert.equal(requests.length, 1); + const data = JSON.parse(requests[0].body).Data; + assert.equal(data.eventType, 'subagent_invocation'); + assert.equal(data.targetName, 'k-developer'); + assert.equal(data.parentAgentName, 'konductor'); + assert.equal(data.parentSessionId, data.sessionId); +}); + +test('a non-Konductor parent agent is never named', (t) => { + for (const parent of ['my-own-agent', 'other-plugin:konductor', undefined]) { + const env = parent === undefined ? {} : { CLAUDE_CODE_AGENT: parent }; + const { requests } = runHook(t, { + eventType: 'subagent-invocation', + payload: { agent_type: 'konductor:k-architect' }, + env, + }); + assert.equal(requests.length, 1, String(parent)); + assert.equal(JSON.parse(requests[0].body).Data.parentAgentName, null, String(parent)); + } +}); + +test('non-Konductor agents send nothing, including other namespaces', (t) => { + for (const agentType of ['Explore', 'general-purpose', 'other-plugin:k-architect', 'konductor:not-ours', 'konductor:', '']) { + for (const eventType of ['agent-invocation', 'subagent-invocation']) { + const { result, requests, home } = runHook(t, { eventType, payload: { agent_type: agentType } }); + assertSilent(result); + assert.equal(requests.length, 0, `${eventType} ${agentType}`); + assert.equal(fs.existsSync(recordPath(home)), false, 'no record minted for a dropped event'); + } + } +}); + +test('a SessionStart with no agent_type sends nothing', (t) => { + const { result, requests } = runHook(t, { + eventType: 'agent-invocation', + payload: { hook_event_name: 'SessionStart', source: 'startup' }, + }); + assertSilent(result); + assert.equal(requests.length, 0); +}); + +// ── Opt-outs ───────────────────────────────────────────────────────────── + +const AGENT_EVENT = { eventType: 'agent-invocation', payload: { agent_type: 'konductor:konductor' } }; + +test('KONDUCTOR_TELEMETRY=off suppresses sending and minting', (t) => { + const { result, requests, home } = runHook(t, { ...AGENT_EVENT, env: { KONDUCTOR_TELEMETRY: 'off' } }); + assertSilent(result); + assert.equal(requests.length, 0); + assert.equal(fs.existsSync(recordPath(home)), false); +}); + +test('telemetry.enabled: false in the project config suppresses sending', (t) => { + const project = scratchDir(t, 'config-project'); + fs.mkdirSync(path.join(project, '.konductor')); + fs.writeFileSync(path.join(project, '.konductor', 'config.yml'), 'telemetry:\n enabled: false\n'); + const { result, requests } = runHook(t, { + eventType: 'agent-invocation', + stdin: JSON.stringify({ cwd: project, session_id: RAW_SESSION_ID, agent_type: 'konductor:konductor' }), + }); + assertSilent(result); + assert.equal(requests.length, 0); +}); + +test('telemetry_consent: false in the machine record suppresses sending', (t) => { + const home = scratchDir(t, 'declined-home'); + const text = writeRecord(home, { + schema_version: 1, + UUID: VALID_UUID, + created_at: '2026-01-01T00:00:00.000Z', + telemetry_consent: false, + }); + const { result, requests } = runHook(t, { ...AGENT_EVENT, home }); + assertSilent(result); + assert.equal(requests.length, 0); + assert.equal(fs.readFileSync(recordPath(home), 'utf8'), text); +}); + +test('the plugin telemetry_enabled option set to false suppresses sending', (t) => { + const { result, requests } = runHook(t, { ...AGENT_EVENT, env: { CLAUDE_PLUGIN_OPTION_TELEMETRY_ENABLED: 'false' } }); + assertSilent(result); + assert.equal(requests.length, 0); + const enabled = runHook(t, { ...AGENT_EVENT, env: { CLAUDE_PLUGIN_OPTION_TELEMETRY_ENABLED: 'true' } }); + assert.equal(enabled.requests.length, 1); +}); + +test('config parser reads telemetry.enabled the way the CLI does', () => { + const off = [ + 'telemetry:\n enabled: false\n', + 'telemetry:\n endpoint: https://x\n enabled: false # off\n', + 'telemetry: {enabled: false}\n', + 'other: 1\ntelemetry:\n enabled: False\n', + ]; + const on = [ + 'telemetry:\n enabled: true\n', + 'telemetry:\n enabled: "false"\n', + 'enabled: false\n', + 'other:\n enabled: false\n', + 'telemetry:\n nested:\n enabled: false\n', + '# telemetry:\n# enabled: false\n', + '', + ]; + for (const text of off) { + assert.equal(hook.configDisablesTelemetry(text), true, JSON.stringify(text)); + } + for (const text of on) { + assert.equal(hook.configDisablesTelemetry(text), false, JSON.stringify(text)); + } +}); + +// ── Identity ───────────────────────────────────────────────────────────── + +test('creates the machine record with mode 0600 when missing', (t) => { + const { requests, home } = runHook(t, AGENT_EVENT); + const record = JSON.parse(fs.readFileSync(recordPath(home), 'utf8')); + assert.deepEqual(Object.keys(record), ['schema_version', 'UUID', 'created_at', 'telemetry_consent']); + assert.equal(record.schema_version, 1); + assert.match(record.UUID, HEX64); + assert.equal(record.telemetry_consent, true); + assert.equal(JSON.parse(requests[0].body).UUID, record.UUID); + if (process.platform !== 'win32') { + assert.equal(fs.statSync(recordPath(home)).mode & 0o777, 0o600); + } + const leftovers = fs.readdirSync(path.join(home, '.konductor')).filter((f) => f.includes('.tmp-')); + assert.deepEqual(leftovers, []); +}); + +test('never overwrites an existing record and sends its UUID', (t) => { + const home = scratchDir(t, 'existing-home'); + const text = writeRecord(home, { + schema_version: 1, + UUID: VALID_UUID, + created_at: '2026-01-01T00:00:00.000Z', + telemetry_consent: true, + }); + const { requests } = runHook(t, { ...AGENT_EVENT, home }); + assert.equal(requests.length, 1); + assert.equal(JSON.parse(requests[0].body).UUID, VALID_UUID); + assert.equal(fs.readFileSync(recordPath(home), 'utf8'), text); +}); + +test('an invalid record resolves to the nil sentinel, is left alone, and sends nothing', (t) => { + const invalid = [ + '{not json', + JSON.stringify({ schema_version: 1, UUID: 'AB'.repeat(32), created_at: 'x', telemetry_consent: true }), + JSON.stringify({ schema_version: 1, UUID: VALID_UUID, telemetry_consent: true }), + JSON.stringify({ schema_version: 2, telemetry_consent: true }), + ]; + for (const text of invalid) { + const home = scratchDir(t, 'invalid-home'); + writeRecord(home, text); + const record = hook.ensureInstance(home); + assert.equal(record.UUID, hook.NIL_UUID_SENTINEL, text); + assert.equal(record.telemetry_consent, false, text); + const { result, requests } = runHook(t, { ...AGENT_EVENT, home }); + assertSilent(result); + assert.equal(requests.length, 0, text); + assert.equal(fs.readFileSync(recordPath(home), 'utf8'), text); + } +}); + +test('a newer-schema record keeps its UUID but fails closed on consent', (t) => { + const home = scratchDir(t, 'newer-home'); + writeRecord(home, { schema_version: 2, UUID: VALID_UUID, telemetry_consent: true }); + assert.deepEqual(hook.ensureInstance(home), { UUID: VALID_UUID, telemetry_consent: false }); +}); + +// ── Endpoint ───────────────────────────────────────────────────────────── + +test('KONDUCTOR_METRICS_ENDPOINT overrides the default when it passes the allowlist', (t) => { + const allowed = runHook(t, { ...AGENT_EVENT, env: { KONDUCTOR_METRICS_ENDPOINT: 'https://collector.example.com/in' } }); + assert.equal(allowed.requests.length, 1); + assert.equal(allowed.requests[0].url, 'https://collector.example.com/in'); + for (const endpoint of ['http://collector.example.com/in', 'https://127.0.0.1/in', 'https://x@localhost/in', 'https://[::1]/in']) { + const { result, requests } = runHook(t, { ...AGENT_EVENT, env: { KONDUCTOR_METRICS_ENDPOINT: endpoint } }); + assertSilent(result); + assert.equal(requests.length, 0, endpoint); + } +}); + +test('host allowlist matches the CLI ranges', () => { + const disallowed = [ + 'localhost', 'LocalHost', '127.0.0.1', '10.1.2.3', '172.16.0.1', '172.31.255.255', '192.168.1.1', + '169.254.169.254', '0.0.0.0', '100.64.0.1', '100.127.255.255', '::', '::1', 'fe80::1', 'febf::1', + 'fc00::1', 'fd12::1', '::ffff:127.0.0.1', '::ffff:7f00:1', '::10.0.0.1', '2002:7f00:1::', + '64:ff9b::a00:1', '64:ff9b::192.168.0.1', + ]; + const allowed = [ + 'metrics.awssolutionsbuilder.com', '8.8.8.8', '172.32.0.1', '100.128.0.1', '2001:db8::1', + '::ffff:808:808', '2002:808:808::', '64:ff9b::808:808', + ]; + for (const host of disallowed) { + assert.equal(hook.isDisallowedHost(host), true, host); + } + for (const host of allowed) { + assert.equal(hook.isDisallowedHost(host), false, host); + } +}); + +// ── Bad input ──────────────────────────────────────────────────────────── + +test('bad input prints nothing and exits 0', (t) => { + const cases = [ + { eventType: 'agent-invocation', stdin: 'not json at all' }, + { eventType: 'agent-invocation', stdin: '' }, + { eventType: 'agent-invocation', stdin: '[1,2,3]' }, + { eventType: 'agent-invocation', stdin: 'null' }, + { eventType: 'subagent-invocation', stdin: '{"agent_type": 42, "session_id": {}}' }, + { eventType: 'agent-invocation', stdin: JSON.stringify({ agent_type: 'konductor', pad: 'x'.repeat(70 * 1024) }) }, + { eventType: 'bogus-event', stdin: JSON.stringify({ agent_type: 'konductor' }) }, + { eventType: undefined, stdin: JSON.stringify({ agent_type: 'konductor' }) }, + ]; + for (const testCase of cases) { + const { result, requests } = runHook(t, testCase); + assertSilent(result); + assert.equal(requests.length, 0, JSON.stringify(testCase.eventType)); + } +}); + +test('an unwritable HOME prints nothing and exits 0', (t) => { + const home = path.join(scratchDir(t, 'file-home'), 'not-a-dir'); + fs.writeFileSync(home, 'x'); + const { result, requests } = runHook(t, { ...AGENT_EVENT, home }); + assertSilent(result); + assert.equal(requests.length, 0); +}); + +// ── hooks.json ─────────────────────────────────────────────────────────── + +test('rendered hooks.json wires both events and matches every plugin agent', (t) => { + const plugin = makePluginTree(t); + const output = path.join(plugin, 'hooks', 'hooks.json'); + const render = spawnSync( + 'python3', + [HOOKS_RENDERER, '--agents-dir', path.join(plugin, 'agents'), '--plugin-json', path.join(plugin, '.claude-plugin', 'plugin.json'), '--output', output], + { encoding: 'utf8' }, + ); + assert.equal(render.status, 0, render.stderr); + const { hooks } = JSON.parse(fs.readFileSync(output, 'utf8')); + assert.deepEqual(Object.keys(hooks), ['SessionStart', 'SubagentStart']); + assert.equal(hooks.SessionStart[0].matcher, 'startup|clear'); + for (const [event, arg] of [['SessionStart', 'agent-invocation'], ['SubagentStart', 'subagent-invocation']]) { + const [handler] = hooks[event][0].hooks; + assert.equal(handler.type, 'command'); + assert.equal(handler.async, true); + assert.equal(handler.command, `node "\${CLAUDE_PLUGIN_ROOT}/hooks/konductor-telemetry.js" ${arg}`); + } + const matcher = new RegExp(hooks.SubagentStart[0].matcher, 'u'); + for (const agent of AGENTS) { + assert.ok(matcher.test(agent), agent); + assert.ok(matcher.test(`konductor:${agent}`), agent); + } + for (const other of ['Explore', 'other:konductor', 'konductor:k-architect-x', 'k-architec']) { + assert.equal(matcher.test(other), false, other); + } +}); + +test('hooks.json rendering fails when an agent name differs from its file name', (t) => { + const plugin = makePluginTree(t); + fs.writeFileSync(path.join(plugin, 'agents', 'k-developer.md'), '---\nname: someone-else\n---\n'); + const render = spawnSync( + 'python3', + [HOOKS_RENDERER, '--agents-dir', path.join(plugin, 'agents'), '--plugin-json', path.join(plugin, '.claude-plugin', 'plugin.json'), '--output', path.join(plugin, 'out.json')], + { encoding: 'utf8' }, + ); + assert.notEqual(render.status, 0); +}); diff --git a/tests/scripts/claude-plugin-telemetry-https-stub.js b/tests/scripts/claude-plugin-telemetry-https-stub.js new file mode 100644 index 00000000..3a49de97 --- /dev/null +++ b/tests/scripts/claude-plugin-telemetry-https-stub.js @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: Apache-2.0 +// Preloaded with `node --require` by claude-plugin-telemetry-hook.test.js. +// Replaces https.request so the hook never reaches the network, and appends +// each request it would have sent to $TELEMETRY_STUB_OUT as one JSON line. +'use strict'; + +const fs = require('node:fs'); +const https = require('node:https'); +const { EventEmitter } = require('node:events'); + +https.request = (url, options) => { + const request = new EventEmitter(); + const chunks = []; + request.setTimeout = () => request; + request.destroy = () => request.emit('close'); + request.write = (chunk) => chunks.push(Buffer.from(chunk)); + request.end = (chunk) => { + if (chunk) { + chunks.push(Buffer.from(chunk)); + } + fs.appendFileSync( + process.env.TELEMETRY_STUB_OUT, + `${JSON.stringify({ + url: String(url), + method: options.method, + headers: options.headers, + body: Buffer.concat(chunks).toString('utf8'), + })}\n`, + ); + setImmediate(() => { + const response = new EventEmitter(); + response.resume = () => setImmediate(() => response.emit('end')); + request.emit('response', response); + }); + }; + return request; +}; diff --git a/tests/test_assemble_claude_plugin_branch_mcp_rewrite.py b/tests/test_assemble_claude_plugin_branch_mcp_rewrite.py index 9eebd0cc..0cc816f5 100644 --- a/tests/test_assemble_claude_plugin_branch_mcp_rewrite.py +++ b/tests/test_assemble_claude_plugin_branch_mcp_rewrite.py @@ -130,3 +130,27 @@ def test_assembled_tree_has_no_repo_root_plugin_layout_leftovers(assembled): # .claude-plugin/plugin.json is the flat tree's own file, written only # under `assembled`, never at the repo root. assert (assembled / ".claude-plugin" / "plugin.json").is_file() + + +def test_assembled_tree_ships_the_telemetry_hook(assembled): + """The telemetry hook, its hooks.json, and the schema copy land in the + flat tree, and the SubagentStart matcher covers every assembled agent.""" + assert (assembled / "hooks" / "konductor-telemetry.js").is_file() + assert (assembled / "docs" / "telemetry-schema.json").read_bytes() == ( + REPO_ROOT / "docs" / "telemetry-schema.json" + ).read_bytes() + + hooks = json.loads((assembled / "hooks" / "hooks.json").read_text(encoding="utf-8"))["hooks"] + assert hooks["SessionStart"][0]["matcher"] == "startup|clear" + for event in ("SessionStart", "SubagentStart"): + handler = hooks[event][0]["hooks"][0] + assert handler["async"] is True + assert '"${CLAUDE_PLUGIN_ROOT}/hooks/konductor-telemetry.js"' in handler["command"] + + plugin = json.loads((assembled / ".claude-plugin" / "plugin.json").read_text(encoding="utf-8")) + assert plugin["userConfig"]["telemetry_enabled"]["type"] == "boolean" + matcher = re.compile(hooks["SubagentStart"][0]["matcher"]) + for agent_file in (assembled / "agents").glob("*.md"): + assert matcher.search(agent_file.stem), agent_file.name + assert matcher.search(f"{plugin['name']}:{agent_file.stem}"), agent_file.name + assert not matcher.search("Explore") From 3c7544f52ec56603ccb772f9d53f74e44b613649 Mon Sep 17 00:00:00 2001 From: Nihit Kasabwala Date: Fri, 16 Oct 2026 17:06:19 +0000 Subject: [PATCH 02/10] fix(plugin): match only plugin-scoped agent names and tag events as claude-plugin Bare agent names come from a CLI install, whose own hook already reports them, so matching them here counted those events twice. Plugin events now send harness "claude-plugin", so they can be told apart from the CLI's, which send null on agent events. --- README.md | 2 +- docs/telemetry-schema.json | 2 +- .../tasks/install-claude-plugin-marketplace.md | 5 +++-- generated/claude-plugin/README.md | 3 ++- scripts/claude-plugin-telemetry-hook.js | 16 +++++++++------- scripts/render-claude-plugin-hooks-json.py | 7 ++++--- .../scripts/claude-plugin-telemetry-hook.test.js | 14 ++++---------- ..._assemble_claude_plugin_branch_mcp_rewrite.py | 2 +- 8 files changed, 25 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 22af7df7..7e21adc2 100644 --- a/README.md +++ b/README.md @@ -364,7 +364,7 @@ This solution sends operational metrics to AWS (the "Data") about the use of thi To opt out, pass `--no-telemetry` to `konductor install`/`konductor update`, set `telemetry.enabled: false` in `.konductor/config.yml`, or set `KONDUCTOR_TELEMETRY=off` in your environment. -**Claude Code plugin.** The marketplace plugin sends `agent_invocation` and `subagent_invocation` events in the same format through a plugin hook, and shares the machine ID in `$HOME/.konductor/telemetry.json` with the CLI. Turn it off with the plugin's `telemetry_enabled` option, or any of the opt-outs above other than `--no-telemetry`. See [Install via the Claude Code plugin marketplace](docs/user-guide/tasks/install-claude-plugin-marketplace.md#telemetry). +**Claude Code plugin.** The marketplace plugin sends `agent_invocation` and `subagent_invocation` events in the same format through a plugin hook, with `claude-plugin` as the harness, and shares the machine ID in `$HOME/.konductor/telemetry.json` with the CLI. Turn it off with the plugin's `telemetry_enabled` option, or any of the opt-outs above other than `--no-telemetry`. See [Install via the Claude Code plugin marketplace](docs/user-guide/tasks/install-claude-plugin-marketplace.md#telemetry). --- diff --git a/docs/telemetry-schema.json b/docs/telemetry-schema.json index 090a91b1..6712c3cc 100644 --- a/docs/telemetry-schema.json +++ b/docs/telemetry-schema.json @@ -65,7 +65,7 @@ "string", "null" ], - "description": "Populated only on cli_error events, copied verbatim from the identity record. Null on every other eventType -- package_uninstalled/package_installed/package_version_updated carry their harness value through targetName instead, not through this field." + "description": "Populated on cli_error events, copied verbatim from the identity record. agent_invocation/subagent_invocation from the Claude Code marketplace plugin carry \"claude-plugin\"; the CLI sends null on those. Null on every other eventType -- package_uninstalled/package_installed/package_version_updated carry their harness value through targetName instead, not through this field." }, "agentVersion": { "type": [ diff --git a/docs/user-guide/tasks/install-claude-plugin-marketplace.md b/docs/user-guide/tasks/install-claude-plugin-marketplace.md index 62ff725a..248766ad 100644 --- a/docs/user-guide/tasks/install-claude-plugin-marketplace.md +++ b/docs/user-guide/tasks/install-claude-plugin-marketplace.md @@ -97,8 +97,9 @@ Add `--scope user` to make that server available across projects. The plugin sends usage telemetry to AWS by default. A hook runs when a session starts with a Konductor agent and when a Konductor agent is delegated to. Each event carries the agent name, the delegating Konductor -agent's name when Claude Code exposes it, the plugin version, and a hashed -session ID. Your own agents and Claude Code's built-in agents are never +agent's name when Claude Code exposes it, the plugin version, a hashed +session ID, and `claude-plugin` as the harness so plugin events can be told +apart from CLI ones. Your own agents and Claude Code's built-in agents are never reported. Prompts, file paths, and tool inputs are never sent. Events also carry the machine ID stored in `~/.konductor/telemetry.json`. diff --git a/generated/claude-plugin/README.md b/generated/claude-plugin/README.md index 6cf199f7..3efbaabc 100644 --- a/generated/claude-plugin/README.md +++ b/generated/claude-plugin/README.md @@ -127,7 +127,8 @@ dependencies; Claude Code runs it with `node`. into the tree). It prints nothing and always exits 0. - `scripts/render-claude-plugin-hooks-json.py` writes `hooks/hooks.json`. The `SubagentStart` matcher lists every file in the assembled `agents/`, - in both the `konductor:` and bare forms. + as `konductor:` only. Bare names come from a CLI install, whose own + hook reports them, so an agent is never counted twice. - The machine ID is shared with the CLI through `~/.konductor/telemetry.json`. Users opt out with the plugin's `telemetry_enabled` option, diff --git a/scripts/claude-plugin-telemetry-hook.js b/scripts/claude-plugin-telemetry-hook.js index 9e0a1c71..2b2932ae 100644 --- a/scripts/claude-plugin-telemetry-hook.js +++ b/scripts/claude-plugin-telemetry-hook.js @@ -29,6 +29,8 @@ const path = require('node:path'); const SOLUTION_ID = 'SO0370'; const DEFAULT_TELEMETRY_ENDPOINT = 'https://metrics.awssolutionsbuilder.com/generic'; const NIL_UUID_SENTINEL = '0'.repeat(64); +// Tells plugin events apart from the CLI's, which send null on agent events. +const HARNESS = 'claude-plugin'; const INSTANCE_SCHEMA_VERSION = 1; const MAX_HOOK_PAYLOAD_BYTES = 64 * 1024; const SEND_TIMEOUT_MS = 3000; @@ -93,7 +95,7 @@ function buildEventBody({ parentSessionId: hashPresentSessionId(uuid, parentSessionId), parentAgentName: nonEmptyString(parentAgentName), errorCode: null, - harness: null, + harness: HARNESS, agentVersion: null, TimeStamp: isoMillis(now), }, @@ -452,16 +454,16 @@ function konductorAgentNames(pluginRoot) { } } -// Claude Code reports plugin agents as `:`; standalone -// installs use the bare name. Returns the bare name, or null when the -// agent isn't one of this plugin's. +// Claude Code reports plugin agents as `:`. A bare name +// belongs to a CLI install, whose own hook reports it. Returns the bare +// name, or null when the agent isn't one of this plugin's. function konductorAgentName(raw, pluginName, agents) { const value = nonEmptyString(raw); - if (value === null) { + const prefix = `${pluginName}:`; + if (value === null || !value.startsWith(prefix)) { return null; } - const prefix = `${pluginName}:`; - const bare = value.startsWith(prefix) ? value.slice(prefix.length) : value; + const bare = value.slice(prefix.length); return agents.has(bare) ? bare : null; } diff --git a/scripts/render-claude-plugin-hooks-json.py b/scripts/render-claude-plugin-hooks-json.py index 9c446d04..aa77c3f1 100755 --- a/scripts/render-claude-plugin-hooks-json.py +++ b/scripts/render-claude-plugin-hooks-json.py @@ -5,8 +5,9 @@ SessionStart (startup|clear) reports agent_invocation and SubagentStart reports subagent_invocation, the same events the CLI wires into Claude Code settings. The SubagentStart matcher is built from the assembled -agents/*.md, so a new agent can't be left out. Claude Code names plugin -agents `:`; the matcher also accepts the bare name. +agents/*.md, so a new agent can't be left out. It matches only the +`:` names Claude Code gives plugin agents; a bare name +belongs to a CLI install, whose own hook reports it. Called by scripts/assemble-claude-plugin-branch.sh. """ @@ -71,7 +72,7 @@ def render(plugin_name: str, names) -> dict: ], "SubagentStart": [ { - "matcher": f"^({regex_escape(plugin_name)}:)?({alternatives})$", + "matcher": f"^{regex_escape(plugin_name)}:({alternatives})$", "hooks": [command("subagent-invocation")], } ], diff --git a/tests/scripts/claude-plugin-telemetry-hook.test.js b/tests/scripts/claude-plugin-telemetry-hook.test.js index 3f7ec50e..dad8ba36 100644 --- a/tests/scripts/claude-plugin-telemetry-hook.test.js +++ b/tests/scripts/claude-plugin-telemetry-hook.test.js @@ -121,7 +121,7 @@ test('body from the exported builder has every schema-required field', () => { } assert.match(body.Data.sessionId, sessionPattern); assert.match(body.Data.eventId, new RegExp(schema.properties.eventId.pattern)); - assert.equal(body.Data.harness, null); + assert.equal(body.Data.harness, 'claude-plugin'); assert.equal(body.Data.errorCode, null); assert.equal(body.Data.agentVersion, null); assert.match(body.TimeStamp, /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d$/); @@ -179,12 +179,6 @@ test('SessionStart for a plugin agent posts one agent_invocation', (t) => { assert.equal(body.Data.parentSessionId, null); }); -test('a bare agent name is reported the same way', (t) => { - const { requests } = runHook(t, { eventType: 'agent-invocation', payload: { agent_type: 'k-architect' } }); - assert.equal(requests.length, 1); - assert.equal(JSON.parse(requests[0].body).Data.targetName, 'k-architect'); -}); - test('SubagentStart reports the target and a Konductor parent from CLAUDE_CODE_AGENT', (t) => { const { requests } = runHook(t, { eventType: 'subagent-invocation', @@ -200,7 +194,7 @@ test('SubagentStart reports the target and a Konductor parent from CLAUDE_CODE_A }); test('a non-Konductor parent agent is never named', (t) => { - for (const parent of ['my-own-agent', 'other-plugin:konductor', undefined]) { + for (const parent of ['my-own-agent', 'other-plugin:konductor', 'konductor', undefined]) { const env = parent === undefined ? {} : { CLAUDE_CODE_AGENT: parent }; const { requests } = runHook(t, { eventType: 'subagent-invocation', @@ -213,7 +207,7 @@ test('a non-Konductor parent agent is never named', (t) => { }); test('non-Konductor agents send nothing, including other namespaces', (t) => { - for (const agentType of ['Explore', 'general-purpose', 'other-plugin:k-architect', 'konductor:not-ours', 'konductor:', '']) { + for (const agentType of ['Explore', 'general-purpose', 'k-architect', 'konductor', 'other-plugin:k-architect', 'konductor:not-ours', 'konductor:', '']) { for (const eventType of ['agent-invocation', 'subagent-invocation']) { const { result, requests, home } = runHook(t, { eventType, payload: { agent_type: agentType } }); assertSilent(result); @@ -440,8 +434,8 @@ test('rendered hooks.json wires both events and matches every plugin agent', (t) } const matcher = new RegExp(hooks.SubagentStart[0].matcher, 'u'); for (const agent of AGENTS) { - assert.ok(matcher.test(agent), agent); assert.ok(matcher.test(`konductor:${agent}`), agent); + assert.equal(matcher.test(agent), false, `bare ${agent}`); } for (const other of ['Explore', 'other:konductor', 'konductor:k-architect-x', 'k-architec']) { assert.equal(matcher.test(other), false, other); diff --git a/tests/test_assemble_claude_plugin_branch_mcp_rewrite.py b/tests/test_assemble_claude_plugin_branch_mcp_rewrite.py index 0cc816f5..710720a7 100644 --- a/tests/test_assemble_claude_plugin_branch_mcp_rewrite.py +++ b/tests/test_assemble_claude_plugin_branch_mcp_rewrite.py @@ -151,6 +151,6 @@ def test_assembled_tree_ships_the_telemetry_hook(assembled): assert plugin["userConfig"]["telemetry_enabled"]["type"] == "boolean" matcher = re.compile(hooks["SubagentStart"][0]["matcher"]) for agent_file in (assembled / "agents").glob("*.md"): - assert matcher.search(agent_file.stem), agent_file.name assert matcher.search(f"{plugin['name']}:{agent_file.stem}"), agent_file.name + assert not matcher.search(agent_file.stem), agent_file.name assert not matcher.search("Explore") From fa5efc4757a4a1f0895664f1a19510ca899aa5d5 Mon Sep 17 00:00:00 2001 From: Nihit Kasabwala Date: Fri, 16 Oct 2026 17:37:59 +0000 Subject: [PATCH 03/10] docs: changelog entry for plugin telemetry --- CHANGELOG.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 28a3e9ef..d368b6b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,16 @@ This changelog follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) [Semantic Versioning](https://semver.org/spec/v2.0.0.html). Entries are consolidated per release, not per individual commit. +## [Unreleased] + +### Added + +- Claude Code plugin telemetry. A plugin hook sends `agent_invocation` and + `subagent_invocation` events for Konductor's own agents, in the CLI's format, with + `claude-plugin` as the harness. It is on by default and shares the machine ID in + `~/.konductor/telemetry.json` with the CLI. Turn it off with the plugin's + `telemetry_enabled` option or any existing opt-out. + ## [1.1.0] - 2026-10-14 ### Added From 2e0c35a1ee9a9ecdcb92611dee70087e8df30c67 Mon Sep 17 00:00:00 2001 From: Nihit Kasabwala Date: Fri, 16 Oct 2026 19:00:23 +0000 Subject: [PATCH 04/10] fix(plugin): harden the telemetry hook's opt-out, endpoint, and record write - Look for .konductor/config.yml in cwd, each parent directory, and $HOME. Any of them setting telemetry.enabled: false turns telemetry off. - Parse the config more fully: strip a BOM, accept quoted keys, and read JSON and flow mappings. If the file mentions telemetry and enabled but does not plainly set enabled to true, treat telemetry as off. Tags, anchors, aliases and merge keys count as unclear. An unreadable file is skipped, as the CLI does. - Pass a lookup to https.request that resolves every address, refuses the connection if any is loopback, private or link-local, and pins the checked address. DNS gets 500 ms, the send 3 s, as in the CLI. - Reject localhost with a trailing dot and *.localhost. - Loop on short writes of the machine record; a write that makes no progress fails closed before the record is published. --- scripts/claude-plugin-telemetry-hook.js | 327 +++++++++++++++--- .../claude-plugin-telemetry-hook.test.js | 200 +++++++++-- .../claude-plugin-telemetry-https-stub.js | 40 ++- 3 files changed, 496 insertions(+), 71 deletions(-) diff --git a/scripts/claude-plugin-telemetry-hook.js b/scripts/claude-plugin-telemetry-hook.js index 2b2932ae..db508ec4 100644 --- a/scripts/claude-plugin-telemetry-hook.js +++ b/scripts/claude-plugin-telemetry-hook.js @@ -20,6 +20,7 @@ 'use strict'; const crypto = require('node:crypto'); +const dns = require('node:dns'); const fs = require('node:fs'); const https = require('node:https'); const net = require('node:net'); @@ -33,6 +34,7 @@ const NIL_UUID_SENTINEL = '0'.repeat(64); const HARNESS = 'claude-plugin'; const INSTANCE_SCHEMA_VERSION = 1; const MAX_HOOK_PAYLOAD_BYTES = 64 * 1024; +const DNS_TIMEOUT_MS = 500; const SEND_TIMEOUT_MS = 3000; const AGENT_INVOCATION = 'agent-invocation'; const SUBAGENT_INVOCATION = 'subagent-invocation'; @@ -108,54 +110,221 @@ function telemetryOffByEnv(env) { return env.KONDUCTOR_TELEMETRY === 'off'; } -// Minimal read of `telemetry.enabled` from .konductor/config.yml: a block -// mapping under a top-level `telemetry:` key, or a one-line flow mapping. -function configDisablesTelemetry(text) { - const isFalse = (value) => ['false', 'False', 'FALSE'].includes(value); - let inSection = false; - let childIndent = null; - for (const rawLine of text.split(/\r?\n/)) { - const line = rawLine.replace(/(^|\s)#.*$/, '').replace(/\s+$/, ''); - if (line.trim() === '' || line === '---') { +const TRUE_SCALARS = ['true', 'True', 'TRUE']; +const FALSE_SCALARS = ['false', 'False', 'FALSE']; + +function stripComment(line) { + let quote = null; + for (let i = 0; i < line.length; i++) { + const c = line[i]; + if (quote) { + if (c === quote) { + quote = null; + } + } else if (c === '"' || c === "'") { + quote = c; + } else if (c === '#' && (i === 0 || /\s/.test(line[i - 1]))) { + return line.slice(0, i); + } + } + return line; +} + +// Turns a YAML flow mapping (or JSON) into JSON text. Plain scalars become +// strings except booleans, null and numbers. A `:` only splits a key from its +// value when followed by a space or after a quoted key, so URLs stay whole. +function flowToJson(text) { + const out = []; + let i = 0; + let afterQuoted = false; + while (i < text.length) { + const c = text[i]; + if (/\s/.test(c)) { + i++; + } else if ('{}[],'.includes(c)) { + out.push(c); + afterQuoted = false; + i++; + } else if (c === ':' && (afterQuoted || i + 1 === text.length || /[\s,}\]]/.test(text[i + 1]))) { + out.push(':'); + afterQuoted = false; + i++; + } else if (c === '"') { + const end = /^"(?:[^"\\]|\\.)*"/.exec(text.slice(i)); + if (!end) { + return null; + } + out.push(end[0]); + afterQuoted = true; + i += end[0].length; + } else if (c === "'") { + const end = /^'(?:[^']|'')*'/.exec(text.slice(i)); + if (!end) { + return null; + } + out.push(JSON.stringify(end[0].slice(1, -1).replace(/''/g, "'"))); + afterQuoted = true; + i += end[0].length; + } else { + let j = i; + while (j < text.length && !',{}[]'.includes(text[j]) && !(text[j] === ':' && (j + 1 === text.length || /[\s,}\]]/.test(text[j + 1])))) { + j++; + } + const plain = text.slice(i, j).trim(); + if (TRUE_SCALARS.includes(plain)) { + out.push('true'); + } else if (FALSE_SCALARS.includes(plain)) { + out.push('false'); + } else if (plain === 'null' || plain === '~') { + out.push('null'); + } else if (/^-?\d+(\.\d+)?$/.test(plain)) { + out.push(plain); + } else { + out.push(JSON.stringify(plain)); + } + afterQuoted = false; + i = j; + } + } + return out.join(''); +} + +function parseFlow(text) { + const json = flowToJson(text); + if (json === null) { + return undefined; + } + try { + return JSON.parse(json); + } catch { + return undefined; + } +} + +const KEY_LINE = /^(\s*)(?:"([^"]*)"|'([^']*)'|([^\s'"{[#][^:]*?))\s*:(?:\s+(.*))?$/; + +// `telemetry.enabled` from the top level of a block mapping: true, false, +// or undefined when it can't be read with confidence. +function blockEnabled(lines) { + let section = null; + for (let index = 0; index < lines.length; index++) { + const line = lines[index]; + const match = KEY_LINE.exec(line); + if (match === null) { + if (line.trimStart().startsWith('-') && section !== null) { + return undefined; + } continue; } - const indent = line.length - line.trimStart().length; - if (indent === 0) { - const top = /^telemetry\s*:\s*(.*)$/.exec(line); - inSection = top !== null; - childIndent = null; - if (top && top[1].startsWith('{')) { - const flow = /[{,]\s*enabled\s*:\s*([^,}\s]+)\s*[,}]/.exec(top[1]); - if (flow && isFalse(flow[1])) { - return true; + const [, indent, dq, sq, plain, rest = ''] = match; + const key = dq ?? sq ?? plain; + if (indent === '') { + if (key !== 'telemetry') { + if (section !== null) { + section.done = true; } + continue; + } + if (section !== null) { + return undefined; + } + section = { values: [], childIndent: null, done: false }; + if (rest.startsWith('{')) { + let flow = rest; + while (!balanced(flow) && index + 1 < lines.length) { + flow += `\n${lines[++index]}`; + } + const value = parseFlow(flow); + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + return undefined; + } + if (Object.hasOwn(value, 'enabled')) { + section.values.push(value.enabled); + } + section.done = true; + } else if (rest !== '') { + return undefined; } continue; } - if (!inSection) { + if (section === null || section.done) { continue; } - if (childIndent === null) { - childIndent = indent; + if (section.childIndent === null) { + section.childIndent = indent.length; } - if (indent !== childIndent) { - continue; - } - const entry = /^\s+enabled\s*:\s*(\S+)$/.exec(line); - if (entry && isFalse(entry[1])) { - return true; + if (indent.length === section.childIndent && key === 'enabled') { + section.values.push(TRUE_SCALARS.includes(rest) ? true : FALSE_SCALARS.includes(rest) ? false : rest); } } - return false; + if (section === null || section.values.length === 0) { + return null; + } + return section.values.length === 1 && typeof section.values[0] === 'boolean' ? section.values[0] : undefined; } -function telemetryOffByConfig(cwd) { - try { - const text = fs.readFileSync(path.join(cwd, '.konductor', 'config.yml'), 'utf8'); - return configDisablesTelemetry(text); - } catch { +function balanced(text) { + let depth = 0; + for (const c of text) { + depth += '{['.includes(c) ? 1 : '}]'.includes(c) ? -1 : 0; + } + return depth <= 0; +} + +// Reads `telemetry.enabled` from .konductor/config.yml. Fails closed: if the +// file mentions both `telemetry` and `enabled` and does not plainly set it to +// true, telemetry is treated as off. YAML tags, anchors, aliases and merge +// keys are never interpreted. +function configDisablesTelemetry(rawText) { + const text = rawText.replace(/^\uFEFF/, ''); + const mentionsSetting = /telemetry/i.test(text) && /enabled/i.test(text); + if (!mentionsSetting) { return false; } + if (/(^|[\s:,[{-])[&*!]|< stripComment(line).replace(/\s+$/, '')) + .filter((line) => line.trim() !== '' && !/^(---|\.\.\.)$/.test(line)); + let enabled; + if (lines.length > 0 && lines[0].startsWith('{')) { + const value = parseFlow(lines.join('\n')); + const section = value && typeof value === 'object' ? value.telemetry : undefined; + enabled = section && typeof section === 'object' && !Array.isArray(section) ? section.enabled : undefined; + } else { + enabled = blockEnabled(lines); + } + return enabled !== true; +} + +function configPaths(cwd, home) { + const dirs = []; + for (let dir = path.resolve(cwd); ; dir = path.dirname(dir)) { + dirs.push(dir); + if (path.dirname(dir) === dir) { + break; + } + } + if (home !== null && !dirs.includes(path.resolve(home))) { + dirs.push(path.resolve(home)); + } + return dirs.map((dir) => path.join(dir, '.konductor', 'config.yml')); +} + +// Nearest first, as the CLI walks install roots, then $HOME. An unreadable +// file is skipped, as the CLI's read_telemetry_config does. +function telemetryOffByConfig(cwd, home) { + return configPaths(cwd, home).some((file) => { + let text; + try { + text = fs.readFileSync(file, 'utf8'); + } catch { + return false; + } + return configDisablesTelemetry(text); + }); } // Plugin userConfig `telemetry_enabled`, exported by Claude Code to hooks. @@ -248,6 +417,17 @@ function createPrivateDir(dir) { fs.chmodSync(dir, 0o700); } +function writeAll(fd, bytes) { + let offset = 0; + while (offset < bytes.length) { + const written = fs.writeSync(fd, bytes, offset, bytes.length - offset); + if (!(written > 0)) { + throw new Error('short write'); + } + offset += written; + } +} + let tempCounter = 0; // Mirrors identity.rs `ensure_instance`: publish with link(2) so a racing @@ -284,7 +464,7 @@ function ensureInstance(home, now = new Date()) { if (process.platform !== 'win32') { fs.fchmodSync(fd, 0o600); } - fs.writeSync(fd, `${JSON.stringify(record, null, 2)}\n`); + writeAll(fd, Buffer.from(`${JSON.stringify(record, null, 2)}\n`, 'utf8')); } finally { fs.closeSync(fd); } @@ -385,8 +565,9 @@ function isDisallowedIpv6(s) { return (s[0] & 0xffc0) === 0xfe80 || (s[0] & 0xfe00) === 0xfc00; } -function isDisallowedHost(host) { - if (host.toLowerCase() === 'localhost') { +function isDisallowedHost(rawHost) { + const host = rawHost.toLowerCase().replace(/\.$/, ''); + if (host === 'localhost' || host.endsWith('.localhost')) { return true; } if (net.isIPv4(host)) { @@ -399,8 +580,13 @@ function isDisallowedHost(host) { return false; } -// No DNS resolution or pinning here, unlike the CLI: a hostname that -// resolves to a private address is not caught. +function allowLocalEndpoint(env) { + return nonEmptyString(env.KONDUCTOR_TELEMETRY_ALLOW_LOCAL_ENDPOINT) !== null; +} + +// Like the CLI's endpoint_host_is_allowed_with_pin: the host is checked +// here, and its resolved addresses are checked by checkedLookup when the +// request connects. function resolveEndpoint(env) { const candidate = nonEmptyString(env.KONDUCTOR_METRICS_ENDPOINT) || DEFAULT_TELEMETRY_ENDPOINT; if (!candidate.startsWith('https://')) { @@ -415,7 +601,7 @@ function resolveEndpoint(env) { if (url.protocol !== 'https:' || url.hostname === '') { return null; } - if (!nonEmptyString(env.KONDUCTOR_TELEMETRY_ALLOW_LOCAL_ENDPOINT)) { + if (!allowLocalEndpoint(env)) { const host = url.hostname.replace(/^\[(.*)\]$/, '$1'); if (isDisallowedHost(host)) { return null; @@ -424,6 +610,46 @@ function resolveEndpoint(env) { return url; } +// A `lookup` for https.request: resolves every address, refuses the +// connection if any is disallowed, and hands the socket the address it +// checked, so a second resolution can't swap it. A slow resolver is denied, +// as in the CLI. +function checkedLookup(lookupAll = (...args) => dns.lookup(...args), timeoutMs = DNS_TIMEOUT_MS) { + return (hostname, options, callback) => { + if (typeof options === 'function') { + callback = options; + options = {}; + } + let settled = false; + const finish = (err, chosen) => { + if (settled) { + return; + } + settled = true; + clearTimeout(timer); + if (err) { + callback(err); + } else if (options && options.all) { + callback(null, [chosen]); + } else { + callback(null, chosen.address, chosen.family); + } + }; + const timer = setTimeout(() => finish(new Error('DNS lookup timed out')), timeoutMs); + lookupAll(hostname, { all: true, verbatim: true }, (err, addresses) => { + if (err) { + finish(err); + } else if (!Array.isArray(addresses) || addresses.length === 0) { + finish(new Error('no addresses')); + } else if (addresses.some(({ address }) => net.isIP(String(address)) === 0 || isDisallowedHost(String(address)))) { + finish(new Error('endpoint resolves to a disallowed address')); + } else { + finish(null, addresses[0]); + } + }); + }; +} + // ── Agent names ────────────────────────────────────────────────────────── function readPluginManifest(pluginRoot) { @@ -492,7 +718,7 @@ function resolveInvocation(eventType, payload, env, pluginName, agents) { // ── Transport ──────────────────────────────────────────────────────────── -function send(url, body) { +function send(url, body, env) { return new Promise((resolve) => { let request; const done = () => { @@ -504,14 +730,18 @@ function send(url, body) { request.destroy(); } done(); - }, SEND_TIMEOUT_MS); + }, DNS_TIMEOUT_MS + SEND_TIMEOUT_MS); try { const payload = Buffer.from(JSON.stringify(body), 'utf8'); - request = https.request(url, { + const options = { method: 'POST', headers: { 'Content-Type': 'application/json', 'Content-Length': payload.length }, timeout: SEND_TIMEOUT_MS, - }); + }; + if (!allowLocalEndpoint(env)) { + options.lookup = checkedLookup(); + } + request = https.request(url, options); request.on('response', (response) => { response.resume(); response.on('end', done); @@ -567,8 +797,9 @@ async function run(argv, env) { if (telemetryOffByEnv(env)) { return; } + const home = homeDir(env); const cwd = nonEmptyString(payload.cwd) || process.cwd(); - if (telemetryOffByConfig(cwd) || telemetryOffByPluginOption(env)) { + if (telemetryOffByConfig(cwd, home) || telemetryOffByPluginOption(env)) { return; } @@ -590,7 +821,6 @@ async function run(argv, env) { if (endpoint === null) { return; } - const home = homeDir(env); if (home === null) { return; } @@ -599,7 +829,7 @@ async function run(argv, env) { return; } - await send(endpoint, buildEventBody({ ...invocation, uuid: record.UUID, version: manifest.version })); + await send(endpoint, buildEventBody({ ...invocation, uuid: record.UUID, version: manifest.version }), env); } function main() { @@ -607,7 +837,7 @@ function main() { process.on('uncaughtException', quietExit); process.on('unhandledRejection', quietExit); // Backstop in case stdin never closes. - setTimeout(quietExit, SEND_TIMEOUT_MS + 2000).unref(); + setTimeout(quietExit, DNS_TIMEOUT_MS + SEND_TIMEOUT_MS + 2000).unref(); run(process.argv.slice(2), process.env).then(quietExit, quietExit); } @@ -617,9 +847,12 @@ if (require.main === module) { module.exports = { DEFAULT_TELEMETRY_ENDPOINT, + INSTANCE_SCHEMA_VERSION, + MAX_HOOK_PAYLOAD_BYTES, NIL_UUID_SENTINEL, SOLUTION_ID, buildEventBody, + checkedLookup, configDisablesTelemetry, ensureInstance, isDisallowedHost, diff --git a/tests/scripts/claude-plugin-telemetry-hook.test.js b/tests/scripts/claude-plugin-telemetry-hook.test.js index dad8ba36..4915db10 100644 --- a/tests/scripts/claude-plugin-telemetry-hook.test.js +++ b/tests/scripts/claude-plugin-telemetry-hook.test.js @@ -271,27 +271,96 @@ test('the plugin telemetry_enabled option set to false suppresses sending', (t) assert.equal(enabled.requests.length, 1); }); -test('config parser reads telemetry.enabled the way the CLI does', () => { - const off = [ - 'telemetry:\n enabled: false\n', - 'telemetry:\n endpoint: https://x\n enabled: false # off\n', - 'telemetry: {enabled: false}\n', - 'other: 1\ntelemetry:\n enabled: False\n', - ]; - const on = [ - 'telemetry:\n enabled: true\n', - 'telemetry:\n enabled: "false"\n', - 'enabled: false\n', - 'other:\n enabled: false\n', - 'telemetry:\n nested:\n enabled: false\n', - '# telemetry:\n# enabled: false\n', - '', - ]; - for (const text of off) { - assert.equal(hook.configDisablesTelemetry(text), true, JSON.stringify(text)); +function writeConfig(dir, text) { + fs.mkdirSync(path.join(dir, '.konductor'), { recursive: true }); + fs.writeFileSync(path.join(dir, '.konductor', 'config.yml'), text); +} + +function agentEventIn(cwd) { + return { + eventType: 'agent-invocation', + stdin: JSON.stringify({ cwd, session_id: RAW_SESSION_ID, agent_type: 'konductor:konductor' }), + }; +} + +test('an opt-out in a parent directory of cwd suppresses sending', (t) => { + const project = scratchDir(t, 'parent-config'); + writeConfig(project, 'telemetry:\n enabled: false\n'); + const subdir = path.join(project, 'packages', 'app'); + fs.mkdirSync(subdir, { recursive: true }); + const { result, requests } = runHook(t, agentEventIn(subdir)); + assertSilent(result); + assert.equal(requests.length, 0); +}); + +test('an opt-out in $HOME/.konductor/config.yml suppresses sending', (t) => { + const home = scratchDir(t, 'home-config'); + writeConfig(home, 'telemetry:\n enabled: false\n'); + const { result, requests } = runHook(t, { ...AGENT_EVENT, home }); + assertSilent(result); + assert.equal(requests.length, 0); + assert.equal(fs.existsSync(recordPath(home)), false); +}); + +test('a nearer config with telemetry on does not override an opt-out further up', (t) => { + const home = scratchDir(t, 'home-config-off'); + writeConfig(home, 'telemetry:\n enabled: false\n'); + const project = scratchDir(t, 'project-config-on'); + writeConfig(project, 'telemetry:\n enabled: true\n'); + const { requests } = runHook(t, { ...agentEventIn(project), home }); + assert.equal(requests.length, 0); +}); + +test('an unreadable config fails open, as the CLI does', (t) => { + const asDirectory = scratchDir(t, 'config-dir'); + fs.mkdirSync(path.join(asDirectory, '.konductor', 'config.yml'), { recursive: true }); + assert.equal(runHook(t, agentEventIn(asDirectory)).requests.length, 1); + if (process.platform !== 'win32' && process.getuid() !== 0) { + const noRead = scratchDir(t, 'config-noread'); + writeConfig(noRead, 'telemetry:\n enabled: false\n'); + fs.chmodSync(path.join(noRead, '.konductor', 'config.yml'), 0o000); + assert.equal(runHook(t, agentEventIn(noRead)).requests.length, 1); + } +}); + +test('config parser treats telemetry.enabled: false as off in every YAML form', () => { + const off = { + block: 'telemetry:\n enabled: false\n', + 'block with comment': 'telemetry:\n endpoint: https://x\n enabled: false # off\n', + 'flow value': 'telemetry: {enabled: false}\n', + 'capitalized, deeper indent': 'other: 1\ntelemetry:\n enabled: False\n', + 'quoted key': '"telemetry":\n "enabled": false\n', + 'single-quoted key': "'telemetry':\n 'enabled': false\n", + BOM: '\uFEFFtelemetry:\n enabled: false\n', + 'flow top': '{telemetry: {enabled: false}}\n', + JSON: '{"telemetry": {"enabled": false, "endpoint": "https://x/y"}}\n', + 'flow quoted': "telemetry: {'enabled': false}\n", + 'flow top quoted': "{'telemetry': {\"enabled\": false}}\n", + 'multi-line flow': 'telemetry: {\n endpoint: https://x/y,\n enabled: false\n}\n', + '!!bool tag': 'telemetry:\n enabled: !!bool false\n', + 'anchor and merge': 'defaults: &d\n enabled: false\ntelemetry:\n <<: *d\n', + 'quoted scalar': 'telemetry:\n enabled: "false"\n', + 'nested enabled': 'telemetry:\n nested:\n enabled: false\n', + 'commented out': '# telemetry:\n# enabled: false\n', + 'duplicate key': 'telemetry:\n enabled: true\n enabled: false\n', + 'unclosed flow': 'telemetry: {enabled: true\n', + }; + const on = { + block: 'telemetry:\n enabled: true\n', + 'block with endpoint': 'telemetry:\n endpoint: https://x/y # here\n enabled: TRUE\n', + JSON: '{"telemetry": {"enabled": true}}', + 'flow top': '{telemetry: {enabled: true, endpoint: https://x/y}}\n', + BOM: '\uFEFFtelemetry:\n enabled: true\n', + 'no enabled key': 'telemetry:\n endpoint: https://x\n', + 'no telemetry key': 'enabled: false\n', + 'other section': 'other:\n enabled: false\n', + empty: '', + }; + for (const [name, text] of Object.entries(off)) { + assert.equal(hook.configDisablesTelemetry(text), true, name); } - for (const text of on) { - assert.equal(hook.configDisablesTelemetry(text), false, JSON.stringify(text)); + for (const [name, text] of Object.entries(on)) { + assert.equal(hook.configDisablesTelemetry(text), false, name); } }); @@ -352,6 +421,33 @@ test('a newer-schema record keeps its UUID but fails closed on consent', (t) => assert.deepEqual(hook.ensureInstance(home), { UUID: VALID_UUID, telemetry_consent: false }); }); +function withWriteSync(t, replacement) { + const original = fs.writeSync; + fs.writeSync = (fd, data, ...rest) => replacement(original, fd, data, ...rest); + t.after(() => { + fs.writeSync = original; + }); +} + +test('a short write still publishes the whole record', (t) => { + const home = scratchDir(t, 'short-write-home'); + withWriteSync(t, (original, fd, data, offset = 0, length) => { + const bytes = Buffer.isBuffer(data) ? data : Buffer.from(String(data)); + return original(fd, bytes, offset, Math.min(7, length ?? bytes.length - offset)); + }); + const record = hook.ensureInstance(home); + assert.equal(record.telemetry_consent, true); + assert.deepEqual(JSON.parse(fs.readFileSync(recordPath(home), 'utf8')), record); +}); + +test('a write that makes no progress fails closed and publishes nothing', (t) => { + const home = scratchDir(t, 'stalled-write-home'); + withWriteSync(t, () => 0); + assert.deepEqual(hook.ensureInstance(home), { UUID: hook.NIL_UUID_SENTINEL, telemetry_consent: false }); + assert.equal(fs.existsSync(recordPath(home)), false); + assert.deepEqual(fs.readdirSync(path.join(home, '.konductor')), []); +}); + // ── Endpoint ───────────────────────────────────────────────────────────── test('KONDUCTOR_METRICS_ENDPOINT overrides the default when it passes the allowlist', (t) => { @@ -384,6 +480,70 @@ test('host allowlist matches the CLI ranges', () => { } }); +test('localhost names are rejected with or without a trailing dot', () => { + for (const host of ['localhost.', 'LOCALHOST.', 'foo.localhost', 'a.b.localhost.', '127.0.0.1.']) { + assert.equal(hook.resolveEndpoint({ KONDUCTOR_METRICS_ENDPOINT: `https://${host}/in` }), null, host); + } + assert.notEqual(hook.resolveEndpoint({ KONDUCTOR_METRICS_ENDPOINT: 'https://notlocalhost.example/in' }), null); +}); + +function lookupWith(answer, options = {}) { + const fakeDns = (hostname, opts, callback) => { + assert.equal(opts.all, true); + if (answer !== 'hang') { + setImmediate(() => callback(null, answer.map((address) => ({ address, family: address.includes(':') ? 6 : 4 })))); + } + }; + const lookup = hook.checkedLookup(fakeDns, 50); + return new Promise((resolve) => { + lookup('collector.example.com', options, (err, address, family) => resolve({ err, address, family })); + }); +} + +test('the connection lookup rejects any resolved address on the denylist', async () => { + for (const answer of [['127.0.0.1'], ['10.1.2.3'], ['::1'], ['8.8.8.8', '192.168.0.1'], ['::ffff:169.254.169.254'], []]) { + const { err } = await lookupWith(answer); + assert.ok(err instanceof Error, answer.join(',')); + } +}); + +test('the connection lookup pins the checked address in both callback shapes', async () => { + const single = await lookupWith(['8.8.8.8', '2001:4860:4860::8888']); + assert.equal(single.err, null); + assert.equal(single.address, '8.8.8.8'); + assert.equal(single.family, 4); + const all = await lookupWith(['2001:4860:4860::8888'], { all: true }); + assert.equal(all.err, null); + assert.deepEqual(all.address, [{ address: '2001:4860:4860::8888', family: 6 }]); +}); + +test('the connection lookup fails closed when DNS does not answer in time', async () => { + const started = Date.now(); + const { err } = await lookupWith('hang'); + assert.ok(err instanceof Error); + assert.ok(Date.now() - started < 1000); +}); + +test('a hostname that resolves to a private address sends nothing', (t) => { + for (const address of ['127.0.0.1', '10.0.0.7', '::1']) { + const { result, requests } = runHook(t, { ...AGENT_EVENT, env: { TELEMETRY_STUB_DNS: address } }); + assertSilent(result); + assert.equal(requests.length, 0, address); + } + const { requests } = runHook(t, { ...AGENT_EVENT, env: { TELEMETRY_STUB_DNS: '8.8.8.8' } }); + assert.equal(requests.length, 1); + assert.equal(requests[0].pinned, '8.8.8.8'); +}); + +test('the local-endpoint escape hatch skips the lookup check', (t) => { + const { requests } = runHook(t, { + ...AGENT_EVENT, + env: { TELEMETRY_STUB_DNS: '127.0.0.1', KONDUCTOR_TELEMETRY_ALLOW_LOCAL_ENDPOINT: '1' }, + }); + assert.equal(requests.length, 1); + assert.equal(requests[0].hasLookup, false); +}); + // ── Bad input ──────────────────────────────────────────────────────────── test('bad input prints nothing and exits 0', (t) => { diff --git a/tests/scripts/claude-plugin-telemetry-https-stub.js b/tests/scripts/claude-plugin-telemetry-https-stub.js index 3a49de97..71446c48 100644 --- a/tests/scripts/claude-plugin-telemetry-https-stub.js +++ b/tests/scripts/claude-plugin-telemetry-https-stub.js @@ -2,28 +2,44 @@ // Preloaded with `node --require` by claude-plugin-telemetry-hook.test.js. // Replaces https.request so the hook never reaches the network, and appends // each request it would have sent to $TELEMETRY_STUB_OUT as one JSON line. +// +// With $TELEMETRY_STUB_DNS set (comma-separated addresses), dns.lookup +// answers with those addresses and the stub runs the request's `lookup` +// option the way a socket would. A lookup error means nothing is recorded. 'use strict'; +const dns = require('node:dns'); const fs = require('node:fs'); const https = require('node:https'); +const net = require('node:net'); const { EventEmitter } = require('node:events'); +const stubAddresses = process.env.TELEMETRY_STUB_DNS; +if (stubAddresses !== undefined) { + dns.lookup = (hostname, options, callback) => { + const addresses = stubAddresses + .split(',') + .filter(Boolean) + .map((address) => ({ address, family: net.isIPv6(address) ? 6 : 4 })); + setImmediate(() => callback(null, addresses)); + }; +} + https.request = (url, options) => { const request = new EventEmitter(); const chunks = []; request.setTimeout = () => request; request.destroy = () => request.emit('close'); request.write = (chunk) => chunks.push(Buffer.from(chunk)); - request.end = (chunk) => { - if (chunk) { - chunks.push(Buffer.from(chunk)); - } + const record = (pinned) => { fs.appendFileSync( process.env.TELEMETRY_STUB_OUT, `${JSON.stringify({ url: String(url), method: options.method, headers: options.headers, + hasLookup: typeof options.lookup === 'function', + pinned, body: Buffer.concat(chunks).toString('utf8'), })}\n`, ); @@ -33,5 +49,21 @@ https.request = (url, options) => { request.emit('response', response); }); }; + request.end = (chunk) => { + if (chunk) { + chunks.push(Buffer.from(chunk)); + } + if (stubAddresses === undefined || typeof options.lookup !== 'function') { + record(null); + return; + } + options.lookup(new URL(String(url)).hostname, { all: false }, (err, address) => { + if (err) { + request.emit('error', err); + return; + } + record(address); + }); + }; return request; }; From 39bd09e573bed2486bc699679b1fa085f012c54f Mon Sep 17 00:00:00 2001 From: Nihit Kasabwala Date: Fri, 16 Oct 2026 19:00:36 +0000 Subject: [PATCH 05/10] test(plugin): cover the record guards and check constants against the Rust code - A symlinked ~/.konductor sends nothing and leaves the target empty; a new ~/.konductor gets mode 0700. - Read NIL_UUID_SENTINEL, INSTANCE_SCHEMA_VERSION, MAX_HOOK_PAYLOAD_BYTES and the host denylist ranges from the Rust sources and compare. - Check each event field's JSON type and pattern against docs/telemetry-schema.json. --- .../claude-plugin-telemetry-hook.test.js | 151 ++++++++++++++++++ 1 file changed, 151 insertions(+) diff --git a/tests/scripts/claude-plugin-telemetry-hook.test.js b/tests/scripts/claude-plugin-telemetry-hook.test.js index 4915db10..b7a016d1 100644 --- a/tests/scripts/claude-plugin-telemetry-hook.test.js +++ b/tests/scripts/claude-plugin-telemetry-hook.test.js @@ -20,6 +20,9 @@ const HOOK_SOURCE = path.join(REPO_ROOT, 'scripts', 'claude-plugin-telemetry-hoo const HOOKS_RENDERER = path.join(REPO_ROOT, 'scripts', 'render-claude-plugin-hooks-json.py'); const SCHEMA_PATH = path.join(REPO_ROOT, 'docs', 'telemetry-schema.json'); const REPORT_RS = path.join(REPO_ROOT, 'cli', 'konductor-rs', 'src', 'cli', 'telemetry', 'report.rs'); +const TELEMETRY_HOOK_RS = path.join(REPO_ROOT, 'cli', 'konductor-rs', 'src', 'cli', 'telemetry_hook.rs'); +const IDENTITY_RS = path.join(REPO_ROOT, 'shared', 'konductor-telemetry', 'src', 'identity.rs'); +const NET_RS = path.join(REPO_ROOT, 'shared', 'konductor-telemetry', 'src', 'net.rs'); const STUB = path.join(__dirname, 'claude-plugin-telemetry-https-stub.js'); const hook = require(HOOK_SOURCE); @@ -155,6 +158,137 @@ test('endpoint and solution id match the CLI source', () => { assert.equal(hook.SOLUTION_ID, solution[1]); }); +function rustConst(file, name) { + const match = new RegExp(`const ${name}: [^=]+=\\s*([^;]+);`).exec(fs.readFileSync(file, 'utf8')); + assert.ok(match, `${name} not found in ${path.relative(REPO_ROOT, file)}`); + return match[1].trim(); +} + +test('identity and payload constants match the Rust sources', () => { + assert.equal(hook.NIL_UUID_SENTINEL, JSON.parse(rustConst(IDENTITY_RS, 'NIL_UUID_SENTINEL'))); + assert.equal(hook.INSTANCE_SCHEMA_VERSION, Number(rustConst(IDENTITY_RS, 'INSTANCE_SCHEMA_VERSION'))); + const maxPayload = rustConst(TELEMETRY_HOOK_RS, 'MAX_HOOK_PAYLOAD_BYTES'); + assert.match(maxPayload, /^[\d\s*]+$/); + assert.equal(hook.MAX_HOOK_PAYLOAD_BYTES, maxPayload.split('*').reduce((n, f) => n * Number(f), 1)); +}); + +function rustFnBody(source, name) { + const start = source.indexOf(`fn ${name}(`); + assert.notEqual(start, -1, `fn ${name} not found in net.rs`); + const open = source.indexOf('{', start); + let depth = 0; + for (let i = open; i < source.length; i++) { + depth += source[i] === '{' ? 1 : source[i] === '}' ? -1 : 0; + if (depth === 0) { + return source.slice(open, i + 1); + } + } + throw new Error(`unbalanced fn ${name}`); +} + +test('host allowlist ranges match net.rs', () => { + const source = fs.readFileSync(NET_RS, 'utf8'); + const hex = (text) => parseInt(text.replace(/_/g, ''), text.startsWith('0b') ? 2 : 16); + const bin = (text) => parseInt(text.replace(/^0b/, '').replace(/_/g, ''), 2); + + // Each std predicate maps to sample addresses inside and just outside its range. + const ipv4Predicates = { + is_unspecified: { in: ['0.0.0.0'], out: [] }, + is_loopback: { in: ['127.0.0.1', '127.255.255.255'], out: ['128.0.0.1'] }, + is_link_local: { in: ['169.254.0.1', '169.254.255.255'], out: ['169.255.0.1'] }, + is_private: { + in: ['10.0.0.1', '10.255.255.255', '172.16.0.1', '172.31.255.255', '192.168.0.1'], + out: ['11.0.0.1', '172.15.255.255', '172.32.0.1', '192.169.0.1'], + }, + is_shared_address_space_cgnat: { in: [], out: [] }, + }; + const ipv4Body = rustFnBody(source, 'is_disallowed_ipv4'); + const used = [...ipv4Body.matchAll(/\b(is_\w+)\(/g)].map((m) => m[1]).sort(); + assert.deepEqual(used, Object.keys(ipv4Predicates).sort(), 'is_disallowed_ipv4 changed; update the JS allowlist'); + for (const { in: inside, out } of Object.values(ipv4Predicates)) { + inside.forEach((host) => assert.equal(hook.isDisallowedHost(host), true, host)); + out.forEach((host) => assert.equal(hook.isDisallowedHost(host), false, host)); + } + + const cgnat = /octets\[0\] == (\d+) && \(octets\[1\] & (0b[01_]+)\) == (0b[01_]+)/.exec( + rustFnBody(source, 'is_shared_address_space_cgnat'), + ); + assert.ok(cgnat, 'CGNAT range not found'); + const [first, mask, value] = [Number(cgnat[1]), bin(cgnat[2]), bin(cgnat[3])]; + const low = value; + const high = value | (~mask & 0xff); + assert.equal(hook.isDisallowedHost(`${first}.${low}.0.0`), true); + assert.equal(hook.isDisallowedHost(`${first}.${high}.255.255`), true); + assert.equal(hook.isDisallowedHost(`${first}.${low - 1}.255.255`), false); + assert.equal(hook.isDisallowedHost(`${first}.${high + 1}.0.0`), false); + + const ipBody = rustFnBody(source, 'is_disallowed_ip'); + const prefixes = [...ipBody.matchAll(/\(v6\.segments\(\)\[0\] & (0x[0-9a-f]+)\) == (0x[0-9a-f]+)/g)]; + assert.equal(prefixes.length, 2, 'IPv6 prefix checks changed; update the JS allowlist'); + for (const [, maskText, valueText] of prefixes) { + const prefixMask = hex(maskText); + const prefix = hex(valueText); + const top = prefix | (~prefixMask & 0xffff); + assert.equal(hook.isDisallowedHost(`${prefix.toString(16)}::1`), true, valueText); + assert.equal(hook.isDisallowedHost(`${top.toString(16)}:ffff::1`), true, valueText); + assert.equal(hook.isDisallowedHost(`${((top + 1) & 0xffff).toString(16)}::1`), false, valueText); + } + + const sixToFour = /segments\[0\] != (0x[0-9a-f]+)/.exec(rustFnBody(source, 'six_to_four_embedded_ipv4')); + assert.ok(sixToFour, '6to4 prefix not found'); + assert.equal(hook.isDisallowedHost(`${hex(sixToFour[1]).toString(16)}:a00:1::`), true); + assert.equal(hook.isDisallowedHost(`${hex(sixToFour[1]).toString(16)}:808:808::`), false); + + const nat64 = /segments\[\.\.6\] != \[(0x[0-9a-f]+), (0x[0-9a-f]+), 0, 0, 0, 0\]/.exec( + rustFnBody(source, 'nat64_well_known_prefix_embedded_ipv4'), + ); + assert.ok(nat64, 'NAT64 prefix not found'); + const nat64Prefix = `${hex(nat64[1]).toString(16)}:${hex(nat64[2]).toString(16)}`; + assert.equal(hook.isDisallowedHost(`${nat64Prefix}::a00:1`), true); + assert.equal(hook.isDisallowedHost(`${nat64Prefix}::808:808`), false); + + assert.match(rustFnBody(source, 'is_disallowed_local_host'), /eq_ignore_ascii_case\("localhost"\)/); +}); + +function jsonType(value) { + if (value === null) return 'null'; + if (Array.isArray(value)) return 'array'; + if (Number.isInteger(value)) return 'integer'; + return typeof value; +} + +test('every event field matches its schema type and pattern', () => { + const schema = JSON.parse(fs.readFileSync(SCHEMA_PATH, 'utf8')); + const bodies = [ + hook.buildEventBody({ eventType: 'agent_invocation', targetName: 'konductor', uuid: VALID_UUID, version: '1', sessionId: RAW_SESSION_ID }), + hook.buildEventBody({ eventType: 'agent_invocation', targetName: 'konductor', uuid: VALID_UUID, version: '1' }), + hook.buildEventBody({ + eventType: 'subagent_invocation', + targetName: 'k-developer', + uuid: VALID_UUID, + version: '1', + sessionId: RAW_SESSION_ID, + parentSessionId: RAW_SESSION_ID, + parentAgentName: 'konductor', + }), + ]; + for (const { Data } of bodies) { + for (const [key, value] of Object.entries(Data)) { + const property = schema.properties[key]; + assert.ok(property, `${key} is not in the schema`); + const types = [].concat(property.type); + const actual = jsonType(value); + assert.ok(types.includes(actual) || (actual === 'integer' && types.includes('number')), `${key}: ${actual} not in ${types}`); + if (typeof value === 'string' && property.pattern) { + assert.match(value, new RegExp(property.pattern), key); + } + if (typeof value === 'string' && property.format === 'date-time') { + assert.ok(!Number.isNaN(Date.parse(value)), key); + } + } + } +}); + // ── End to end ─────────────────────────────────────────────────────────── test('SessionStart for a plugin agent posts one agent_invocation', (t) => { @@ -421,6 +555,23 @@ test('a newer-schema record keeps its UUID but fails closed on consent', (t) => assert.deepEqual(hook.ensureInstance(home), { UUID: VALID_UUID, telemetry_consent: false }); }); +test('creates ~/.konductor with mode 0700', { skip: process.platform === 'win32' }, (t) => { + const { requests, home } = runHook(t, AGENT_EVENT); + assert.equal(requests.length, 1); + assert.equal(fs.lstatSync(path.join(home, '.konductor')).mode & 0o777, 0o700); +}); + +test('a symlinked ~/.konductor sends nothing and writes nothing into the target', { skip: process.platform === 'win32' }, (t) => { + const home = scratchDir(t, 'symlink-home'); + const target = scratchDir(t, 'symlink-target'); + fs.symlinkSync(target, path.join(home, '.konductor')); + const { result, requests } = runHook(t, { ...AGENT_EVENT, home }); + assertSilent(result); + assert.equal(requests.length, 0); + assert.deepEqual(fs.readdirSync(target), []); + assert.equal(fs.lstatSync(path.join(home, '.konductor')).isSymbolicLink(), true); +}); + function withWriteSync(t, replacement) { const original = fs.writeSync; fs.writeSync = (fd, data, ...rest) => replacement(original, fd, data, ...rest); From 5cfd839a6ef07fc60d1bd6862c21ff9c16948fef Mon Sep 17 00:00:00 2001 From: Nihit Kasabwala Date: Fri, 16 Oct 2026 19:00:36 +0000 Subject: [PATCH 06/10] docs(plugin): describe every telemetry field and where the opt-out is read The telemetry_enabled option now names the delegating agent and the claude-plugin harness tag. The install guide says config.yml is read from the project, its parents, and $HOME, and that only KONDUCTOR_METRICS_ENDPOINT changes the plugin's endpoint. --- docs/user-guide/tasks/install-claude-plugin-marketplace.md | 6 +++++- scripts/claude-plugin.template.json | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/user-guide/tasks/install-claude-plugin-marketplace.md b/docs/user-guide/tasks/install-claude-plugin-marketplace.md index 248766ad..9cb840f7 100644 --- a/docs/user-guide/tasks/install-claude-plugin-marketplace.md +++ b/docs/user-guide/tasks/install-claude-plugin-marketplace.md @@ -110,10 +110,14 @@ To opt out, do any one of these: - Turn off the plugin's `telemetry_enabled` option in `/config`. - Set `KONDUCTOR_TELEMETRY=off` in your environment. -- Set `telemetry.enabled: false` in the project's `.konductor/config.yml`. +- Set `telemetry.enabled: false` in `.konductor/config.yml` in the project, + any directory above it, or `$HOME`. - Set `"telemetry_consent": false` in `~/.konductor/telemetry.json`. This also stops the CLI from reporting on this machine. +The plugin sends to the default endpoint unless `KONDUCTOR_METRICS_ENDPOINT` +is set. It does not read `telemetry.endpoint` from `.konductor/config.yml`. + See [Data Collection](../../../README.md#data-collection) for the full details and the event schema. diff --git a/scripts/claude-plugin.template.json b/scripts/claude-plugin.template.json index a91357da..1e56beb1 100644 --- a/scripts/claude-plugin.template.json +++ b/scripts/claude-plugin.template.json @@ -32,7 +32,7 @@ "telemetry_enabled": { "type": "boolean", "title": "Send usage telemetry to AWS", - "description": "Report which Konductor agents run. Each event carries the agent name, the plugin version, a hashed session ID, and the machine ID in ~/.konductor/telemetry.json, which the Konductor CLI also uses. See Data Collection in the README.", + "description": "Report which Konductor agents run. Each event carries the agent name, the delegating Konductor agent's name on a delegation, the plugin version, a hashed session ID, the claude-plugin harness tag, and the machine ID in ~/.konductor/telemetry.json, which the Konductor CLI also uses. See Data Collection in the README.", "default": true } }, From 066d5d2049aeeb3ee4fc24959c83138f23e79ddf Mon Sep 17 00:00:00 2001 From: Nihit Kasabwala Date: Tue, 20 Oct 2026 17:26:02 +0000 Subject: [PATCH 07/10] fix(plugin): treat a missing telemetry.enabled as on, and ignore comments A config.yml with a telemetry block but no enabled key turned plugin telemetry off, while the CLI treats it as on. Comments also affected the reading, so 'enabled: true # see **docs**' counted as off. Only an enabled key set to something other than plain true now turns it off. --- scripts/claude-plugin-telemetry-hook.js | 43 +++++++++++-------- .../claude-plugin-telemetry-hook.test.js | 18 +++++++- 2 files changed, 42 insertions(+), 19 deletions(-) diff --git a/scripts/claude-plugin-telemetry-hook.js b/scripts/claude-plugin-telemetry-hook.js index db508ec4..5284faa2 100644 --- a/scripts/claude-plugin-telemetry-hook.js +++ b/scripts/claude-plugin-telemetry-hook.js @@ -271,32 +271,41 @@ function balanced(text) { return depth <= 0; } -// Reads `telemetry.enabled` from .konductor/config.yml. Fails closed: if the -// file mentions both `telemetry` and `enabled` and does not plainly set it to -// true, telemetry is treated as off. YAML tags, anchors, aliases and merge -// keys are never interpreted. +// Reads `telemetry.enabled` from .konductor/config.yml. A missing setting +// leaves telemetry on, as in the CLI. Fails closed: if `enabled` is set but +// not plainly to true, telemetry is treated as off. YAML tags, anchors, +// aliases and merge keys are never interpreted. function configDisablesTelemetry(rawText) { - const text = rawText.replace(/^\uFEFF/, ''); - const mentionsSetting = /telemetry/i.test(text) && /enabled/i.test(text); - if (!mentionsSetting) { + const lines = rawText + .replace(/^\uFEFF/, '') + .split(/\r?\n/) + .map((line) => stripComment(line).replace(/\s+$/, '')) + .filter((line) => line.trim() !== '' && !/^(---|\.\.\.)$/.test(line)); + const body = lines.join('\n'); + if (!/telemetry/i.test(body) || !/enabled/i.test(body)) { return false; } - if (/(^|[\s:,[{-])[&*!]|< stripComment(line).replace(/\s+$/, '')) - .filter((line) => line.trim() !== '' && !/^(---|\.\.\.)$/.test(line)); let enabled; - if (lines.length > 0 && lines[0].startsWith('{')) { - const value = parseFlow(lines.join('\n')); - const section = value && typeof value === 'object' ? value.telemetry : undefined; - enabled = section && typeof section === 'object' && !Array.isArray(section) ? section.enabled : undefined; + if (lines[0].startsWith('{')) { + const value = parseFlow(body); + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + return true; + } + const section = value.telemetry; + if (section === undefined) { + return false; + } + if (section === null || typeof section !== 'object' || Array.isArray(section)) { + return true; + } + enabled = Object.hasOwn(section, 'enabled') ? section.enabled : null; } else { enabled = blockEnabled(lines); } - return enabled !== true; + return enabled !== null && enabled !== true; } function configPaths(cwd, home) { diff --git a/tests/scripts/claude-plugin-telemetry-hook.test.js b/tests/scripts/claude-plugin-telemetry-hook.test.js index b7a016d1..da76f1e7 100644 --- a/tests/scripts/claude-plugin-telemetry-hook.test.js +++ b/tests/scripts/claude-plugin-telemetry-hook.test.js @@ -474,8 +474,6 @@ test('config parser treats telemetry.enabled: false as off in every YAML form', '!!bool tag': 'telemetry:\n enabled: !!bool false\n', 'anchor and merge': 'defaults: &d\n enabled: false\ntelemetry:\n <<: *d\n', 'quoted scalar': 'telemetry:\n enabled: "false"\n', - 'nested enabled': 'telemetry:\n nested:\n enabled: false\n', - 'commented out': '# telemetry:\n# enabled: false\n', 'duplicate key': 'telemetry:\n enabled: true\n enabled: false\n', 'unclosed flow': 'telemetry: {enabled: true\n', }; @@ -488,6 +486,8 @@ test('config parser treats telemetry.enabled: false as off in every YAML form', 'no enabled key': 'telemetry:\n endpoint: https://x\n', 'no telemetry key': 'enabled: false\n', 'other section': 'other:\n enabled: false\n', + 'nested enabled': 'telemetry:\n nested:\n enabled: false\n', + 'commented out': '# telemetry:\n# enabled: false\n', empty: '', }; for (const [name, text] of Object.entries(off)) { @@ -763,3 +763,17 @@ test('hooks.json rendering fails when an agent name differs from its file name', ); assert.notEqual(render.status, 0); }); + +test('a config without telemetry.enabled leaves telemetry on, as the CLI does', () => { + const { configDisablesTelemetry } = require('../../scripts/claude-plugin-telemetry-hook.js'); + assert.equal(configDisablesTelemetry('telemetry:\n endpoint: https://metrics.example.com/generic\nmcp:\n enabled: true\n'), false); + assert.equal(configDisablesTelemetry('{"telemetry": {"endpoint": "x"}, "mcp": {"enabled": true}}\n'), false); + assert.equal(configDisablesTelemetry('mcp:\n enabled: false\n'), false); +}); + +test('comments do not affect the telemetry.enabled reading', () => { + const { configDisablesTelemetry } = require('../../scripts/claude-plugin-telemetry-hook.js'); + assert.equal(configDisablesTelemetry('telemetry:\n enabled: true # see **docs**\n'), false); + assert.equal(configDisablesTelemetry('# telemetry enabled: false\nmcp: {}\n'), false); + assert.equal(configDisablesTelemetry('telemetry:\n enabled: false # opted out\n'), true); +}); From 2869c770a772f55416ce2c685c8d05982043f430 Mon Sep 17 00:00:00 2001 From: Nihit Kasabwala Date: Tue, 20 Oct 2026 17:37:43 +0000 Subject: [PATCH 08/10] refactor(plugin): always send telemetry to the default endpoint The plugin ignores telemetry.endpoint, so KONDUCTOR_METRICS_ENDPOINT was its only way to change the endpoint, and nothing needs that. Dropping it removes the host allowlist, address parsers, DNS check and escape hatch that guarded it, along with their tests. Tests still stub https.request. --- .../install-claude-plugin-marketplace.md | 4 +- scripts/claude-plugin-telemetry-hook.js | 183 +----------------- .../claude-plugin-telemetry-hook.test.js | 175 ----------------- .../claude-plugin-telemetry-https-stub.js | 33 +--- 4 files changed, 8 insertions(+), 387 deletions(-) diff --git a/docs/user-guide/tasks/install-claude-plugin-marketplace.md b/docs/user-guide/tasks/install-claude-plugin-marketplace.md index 9cb840f7..801004bd 100644 --- a/docs/user-guide/tasks/install-claude-plugin-marketplace.md +++ b/docs/user-guide/tasks/install-claude-plugin-marketplace.md @@ -115,8 +115,8 @@ To opt out, do any one of these: - Set `"telemetry_consent": false` in `~/.konductor/telemetry.json`. This also stops the CLI from reporting on this machine. -The plugin sends to the default endpoint unless `KONDUCTOR_METRICS_ENDPOINT` -is set. It does not read `telemetry.endpoint` from `.konductor/config.yml`. +The plugin always sends to the default endpoint. It ignores +`KONDUCTOR_METRICS_ENDPOINT` and `telemetry.endpoint`. See [Data Collection](../../../README.md#data-collection) for the full details and the event schema. diff --git a/scripts/claude-plugin-telemetry-hook.js b/scripts/claude-plugin-telemetry-hook.js index 5284faa2..be631515 100644 --- a/scripts/claude-plugin-telemetry-hook.js +++ b/scripts/claude-plugin-telemetry-hook.js @@ -20,10 +20,8 @@ 'use strict'; const crypto = require('node:crypto'); -const dns = require('node:dns'); const fs = require('node:fs'); const https = require('node:https'); -const net = require('node:net'); const os = require('node:os'); const path = require('node:path'); @@ -34,7 +32,6 @@ const NIL_UUID_SENTINEL = '0'.repeat(64); const HARNESS = 'claude-plugin'; const INSTANCE_SCHEMA_VERSION = 1; const MAX_HOOK_PAYLOAD_BYTES = 64 * 1024; -const DNS_TIMEOUT_MS = 500; const SEND_TIMEOUT_MS = 3000; const AGENT_INVOCATION = 'agent-invocation'; const SUBAGENT_INVOCATION = 'subagent-invocation'; @@ -497,168 +494,6 @@ function ensureInstance(home, now = new Date()) { } } -// ── Endpoint allowlist (same rules as konductor-telemetry-report.sh) ───── - -function parseIpv4(text) { - const parts = text.split('.'); - if (parts.length !== 4 || !parts.every((p) => /^\d{1,3}$/.test(p) && Number(p) <= 255)) { - return null; - } - return parts.map(Number); -} - -function parseIpv6(text) { - let head = text; - let tail = []; - const lastColon = text.lastIndexOf(':'); - if (text.slice(lastColon + 1).includes('.')) { - const v4 = parseIpv4(text.slice(lastColon + 1)); - if (v4 === null) { - return null; - } - head = text.slice(0, lastColon + 1); - if (head.endsWith(':') && !head.endsWith('::')) { - head = head.slice(0, -1); - } - tail = [(v4[0] << 8) | v4[1], (v4[2] << 8) | v4[3]]; - } - const hextets = (part) => (part === '' ? [] : part.split(':').map((h) => parseInt(h, 16))); - const halves = head.split('::'); - if (halves.length > 2) { - return null; - } - const left = hextets(halves[0]); - const right = halves.length === 2 ? hextets(halves[1]) : []; - const fill = 8 - left.length - right.length - tail.length; - if (fill < 0 || (halves.length === 1 && fill !== 0)) { - return null; - } - const segments = [...left, ...new Array(fill).fill(0), ...right, ...tail]; - return segments.every((s) => Number.isInteger(s) && s >= 0 && s <= 0xffff) ? segments : null; -} - -function isDisallowedIpv4([a, b]) { - return ( - a === 0 || - a === 127 || - a === 10 || - (a === 169 && b === 254) || - (a === 172 && b >= 16 && b <= 31) || - (a === 192 && b === 168) || - (a === 100 && (b & 0xc0) === 0x40) - ); -} - -function embeddedIpv4(hi, lo) { - return [hi >> 8, hi & 0xff, lo >> 8, lo & 0xff]; -} - -function isDisallowedIpv6(s) { - const zeroThrough = (n) => s.slice(0, n).every((x) => x === 0); - if (zeroThrough(5) && s[5] === 0xffff) { - return isDisallowedIpv4(embeddedIpv4(s[6], s[7])); - } - if (zeroThrough(6)) { - // `::` and `::1`; anything else here is the IPv4-compatible form. - if (s[6] === 0 && (s[7] === 0 || s[7] === 1)) { - return true; - } - return isDisallowedIpv4(embeddedIpv4(s[6], s[7])); - } - if (s[0] === 0x2002) { - return isDisallowedIpv4(embeddedIpv4(s[1], s[2])); - } - if (s[0] === 0x64 && s[1] === 0xff9b && s.slice(2, 6).every((x) => x === 0)) { - return isDisallowedIpv4(embeddedIpv4(s[6], s[7])); - } - return (s[0] & 0xffc0) === 0xfe80 || (s[0] & 0xfe00) === 0xfc00; -} - -function isDisallowedHost(rawHost) { - const host = rawHost.toLowerCase().replace(/\.$/, ''); - if (host === 'localhost' || host.endsWith('.localhost')) { - return true; - } - if (net.isIPv4(host)) { - return isDisallowedIpv4(parseIpv4(host)); - } - if (net.isIPv6(host)) { - const segments = parseIpv6(host); - return segments === null || isDisallowedIpv6(segments); - } - return false; -} - -function allowLocalEndpoint(env) { - return nonEmptyString(env.KONDUCTOR_TELEMETRY_ALLOW_LOCAL_ENDPOINT) !== null; -} - -// Like the CLI's endpoint_host_is_allowed_with_pin: the host is checked -// here, and its resolved addresses are checked by checkedLookup when the -// request connects. -function resolveEndpoint(env) { - const candidate = nonEmptyString(env.KONDUCTOR_METRICS_ENDPOINT) || DEFAULT_TELEMETRY_ENDPOINT; - if (!candidate.startsWith('https://')) { - return null; - } - let url; - try { - url = new URL(candidate); - } catch { - return null; - } - if (url.protocol !== 'https:' || url.hostname === '') { - return null; - } - if (!allowLocalEndpoint(env)) { - const host = url.hostname.replace(/^\[(.*)\]$/, '$1'); - if (isDisallowedHost(host)) { - return null; - } - } - return url; -} - -// A `lookup` for https.request: resolves every address, refuses the -// connection if any is disallowed, and hands the socket the address it -// checked, so a second resolution can't swap it. A slow resolver is denied, -// as in the CLI. -function checkedLookup(lookupAll = (...args) => dns.lookup(...args), timeoutMs = DNS_TIMEOUT_MS) { - return (hostname, options, callback) => { - if (typeof options === 'function') { - callback = options; - options = {}; - } - let settled = false; - const finish = (err, chosen) => { - if (settled) { - return; - } - settled = true; - clearTimeout(timer); - if (err) { - callback(err); - } else if (options && options.all) { - callback(null, [chosen]); - } else { - callback(null, chosen.address, chosen.family); - } - }; - const timer = setTimeout(() => finish(new Error('DNS lookup timed out')), timeoutMs); - lookupAll(hostname, { all: true, verbatim: true }, (err, addresses) => { - if (err) { - finish(err); - } else if (!Array.isArray(addresses) || addresses.length === 0) { - finish(new Error('no addresses')); - } else if (addresses.some(({ address }) => net.isIP(String(address)) === 0 || isDisallowedHost(String(address)))) { - finish(new Error('endpoint resolves to a disallowed address')); - } else { - finish(null, addresses[0]); - } - }); - }; -} - // ── Agent names ────────────────────────────────────────────────────────── function readPluginManifest(pluginRoot) { @@ -727,7 +562,7 @@ function resolveInvocation(eventType, payload, env, pluginName, agents) { // ── Transport ──────────────────────────────────────────────────────────── -function send(url, body, env) { +function send(url, body) { return new Promise((resolve) => { let request; const done = () => { @@ -739,7 +574,7 @@ function send(url, body, env) { request.destroy(); } done(); - }, DNS_TIMEOUT_MS + SEND_TIMEOUT_MS); + }, SEND_TIMEOUT_MS); try { const payload = Buffer.from(JSON.stringify(body), 'utf8'); const options = { @@ -747,9 +582,6 @@ function send(url, body, env) { headers: { 'Content-Type': 'application/json', 'Content-Length': payload.length }, timeout: SEND_TIMEOUT_MS, }; - if (!allowLocalEndpoint(env)) { - options.lookup = checkedLookup(); - } request = https.request(url, options); request.on('response', (response) => { response.resume(); @@ -826,10 +658,6 @@ async function run(argv, env) { if (invocation === null) { return; } - const endpoint = resolveEndpoint(env); - if (endpoint === null) { - return; - } if (home === null) { return; } @@ -838,7 +666,7 @@ async function run(argv, env) { return; } - await send(endpoint, buildEventBody({ ...invocation, uuid: record.UUID, version: manifest.version }), env); + await send(DEFAULT_TELEMETRY_ENDPOINT, buildEventBody({ ...invocation, uuid: record.UUID, version: manifest.version })); } function main() { @@ -846,7 +674,7 @@ function main() { process.on('uncaughtException', quietExit); process.on('unhandledRejection', quietExit); // Backstop in case stdin never closes. - setTimeout(quietExit, DNS_TIMEOUT_MS + SEND_TIMEOUT_MS + 2000).unref(); + setTimeout(quietExit, SEND_TIMEOUT_MS + 2000).unref(); run(process.argv.slice(2), process.env).then(quietExit, quietExit); } @@ -861,12 +689,9 @@ module.exports = { NIL_UUID_SENTINEL, SOLUTION_ID, buildEventBody, - checkedLookup, configDisablesTelemetry, ensureInstance, - isDisallowedHost, konductorAgentName, readInstanceRaw, - resolveEndpoint, resolveInvocation, }; diff --git a/tests/scripts/claude-plugin-telemetry-hook.test.js b/tests/scripts/claude-plugin-telemetry-hook.test.js index da76f1e7..62ae6017 100644 --- a/tests/scripts/claude-plugin-telemetry-hook.test.js +++ b/tests/scripts/claude-plugin-telemetry-hook.test.js @@ -22,7 +22,6 @@ const SCHEMA_PATH = path.join(REPO_ROOT, 'docs', 'telemetry-schema.json'); const REPORT_RS = path.join(REPO_ROOT, 'cli', 'konductor-rs', 'src', 'cli', 'telemetry', 'report.rs'); const TELEMETRY_HOOK_RS = path.join(REPO_ROOT, 'cli', 'konductor-rs', 'src', 'cli', 'telemetry_hook.rs'); const IDENTITY_RS = path.join(REPO_ROOT, 'shared', 'konductor-telemetry', 'src', 'identity.rs'); -const NET_RS = path.join(REPO_ROOT, 'shared', 'konductor-telemetry', 'src', 'net.rs'); const STUB = path.join(__dirname, 'claude-plugin-telemetry-https-stub.js'); const hook = require(HOOK_SOURCE); @@ -172,84 +171,6 @@ test('identity and payload constants match the Rust sources', () => { assert.equal(hook.MAX_HOOK_PAYLOAD_BYTES, maxPayload.split('*').reduce((n, f) => n * Number(f), 1)); }); -function rustFnBody(source, name) { - const start = source.indexOf(`fn ${name}(`); - assert.notEqual(start, -1, `fn ${name} not found in net.rs`); - const open = source.indexOf('{', start); - let depth = 0; - for (let i = open; i < source.length; i++) { - depth += source[i] === '{' ? 1 : source[i] === '}' ? -1 : 0; - if (depth === 0) { - return source.slice(open, i + 1); - } - } - throw new Error(`unbalanced fn ${name}`); -} - -test('host allowlist ranges match net.rs', () => { - const source = fs.readFileSync(NET_RS, 'utf8'); - const hex = (text) => parseInt(text.replace(/_/g, ''), text.startsWith('0b') ? 2 : 16); - const bin = (text) => parseInt(text.replace(/^0b/, '').replace(/_/g, ''), 2); - - // Each std predicate maps to sample addresses inside and just outside its range. - const ipv4Predicates = { - is_unspecified: { in: ['0.0.0.0'], out: [] }, - is_loopback: { in: ['127.0.0.1', '127.255.255.255'], out: ['128.0.0.1'] }, - is_link_local: { in: ['169.254.0.1', '169.254.255.255'], out: ['169.255.0.1'] }, - is_private: { - in: ['10.0.0.1', '10.255.255.255', '172.16.0.1', '172.31.255.255', '192.168.0.1'], - out: ['11.0.0.1', '172.15.255.255', '172.32.0.1', '192.169.0.1'], - }, - is_shared_address_space_cgnat: { in: [], out: [] }, - }; - const ipv4Body = rustFnBody(source, 'is_disallowed_ipv4'); - const used = [...ipv4Body.matchAll(/\b(is_\w+)\(/g)].map((m) => m[1]).sort(); - assert.deepEqual(used, Object.keys(ipv4Predicates).sort(), 'is_disallowed_ipv4 changed; update the JS allowlist'); - for (const { in: inside, out } of Object.values(ipv4Predicates)) { - inside.forEach((host) => assert.equal(hook.isDisallowedHost(host), true, host)); - out.forEach((host) => assert.equal(hook.isDisallowedHost(host), false, host)); - } - - const cgnat = /octets\[0\] == (\d+) && \(octets\[1\] & (0b[01_]+)\) == (0b[01_]+)/.exec( - rustFnBody(source, 'is_shared_address_space_cgnat'), - ); - assert.ok(cgnat, 'CGNAT range not found'); - const [first, mask, value] = [Number(cgnat[1]), bin(cgnat[2]), bin(cgnat[3])]; - const low = value; - const high = value | (~mask & 0xff); - assert.equal(hook.isDisallowedHost(`${first}.${low}.0.0`), true); - assert.equal(hook.isDisallowedHost(`${first}.${high}.255.255`), true); - assert.equal(hook.isDisallowedHost(`${first}.${low - 1}.255.255`), false); - assert.equal(hook.isDisallowedHost(`${first}.${high + 1}.0.0`), false); - - const ipBody = rustFnBody(source, 'is_disallowed_ip'); - const prefixes = [...ipBody.matchAll(/\(v6\.segments\(\)\[0\] & (0x[0-9a-f]+)\) == (0x[0-9a-f]+)/g)]; - assert.equal(prefixes.length, 2, 'IPv6 prefix checks changed; update the JS allowlist'); - for (const [, maskText, valueText] of prefixes) { - const prefixMask = hex(maskText); - const prefix = hex(valueText); - const top = prefix | (~prefixMask & 0xffff); - assert.equal(hook.isDisallowedHost(`${prefix.toString(16)}::1`), true, valueText); - assert.equal(hook.isDisallowedHost(`${top.toString(16)}:ffff::1`), true, valueText); - assert.equal(hook.isDisallowedHost(`${((top + 1) & 0xffff).toString(16)}::1`), false, valueText); - } - - const sixToFour = /segments\[0\] != (0x[0-9a-f]+)/.exec(rustFnBody(source, 'six_to_four_embedded_ipv4')); - assert.ok(sixToFour, '6to4 prefix not found'); - assert.equal(hook.isDisallowedHost(`${hex(sixToFour[1]).toString(16)}:a00:1::`), true); - assert.equal(hook.isDisallowedHost(`${hex(sixToFour[1]).toString(16)}:808:808::`), false); - - const nat64 = /segments\[\.\.6\] != \[(0x[0-9a-f]+), (0x[0-9a-f]+), 0, 0, 0, 0\]/.exec( - rustFnBody(source, 'nat64_well_known_prefix_embedded_ipv4'), - ); - assert.ok(nat64, 'NAT64 prefix not found'); - const nat64Prefix = `${hex(nat64[1]).toString(16)}:${hex(nat64[2]).toString(16)}`; - assert.equal(hook.isDisallowedHost(`${nat64Prefix}::a00:1`), true); - assert.equal(hook.isDisallowedHost(`${nat64Prefix}::808:808`), false); - - assert.match(rustFnBody(source, 'is_disallowed_local_host'), /eq_ignore_ascii_case\("localhost"\)/); -}); - function jsonType(value) { if (value === null) return 'null'; if (Array.isArray(value)) return 'array'; @@ -601,102 +522,6 @@ test('a write that makes no progress fails closed and publishes nothing', (t) => // ── Endpoint ───────────────────────────────────────────────────────────── -test('KONDUCTOR_METRICS_ENDPOINT overrides the default when it passes the allowlist', (t) => { - const allowed = runHook(t, { ...AGENT_EVENT, env: { KONDUCTOR_METRICS_ENDPOINT: 'https://collector.example.com/in' } }); - assert.equal(allowed.requests.length, 1); - assert.equal(allowed.requests[0].url, 'https://collector.example.com/in'); - for (const endpoint of ['http://collector.example.com/in', 'https://127.0.0.1/in', 'https://x@localhost/in', 'https://[::1]/in']) { - const { result, requests } = runHook(t, { ...AGENT_EVENT, env: { KONDUCTOR_METRICS_ENDPOINT: endpoint } }); - assertSilent(result); - assert.equal(requests.length, 0, endpoint); - } -}); - -test('host allowlist matches the CLI ranges', () => { - const disallowed = [ - 'localhost', 'LocalHost', '127.0.0.1', '10.1.2.3', '172.16.0.1', '172.31.255.255', '192.168.1.1', - '169.254.169.254', '0.0.0.0', '100.64.0.1', '100.127.255.255', '::', '::1', 'fe80::1', 'febf::1', - 'fc00::1', 'fd12::1', '::ffff:127.0.0.1', '::ffff:7f00:1', '::10.0.0.1', '2002:7f00:1::', - '64:ff9b::a00:1', '64:ff9b::192.168.0.1', - ]; - const allowed = [ - 'metrics.awssolutionsbuilder.com', '8.8.8.8', '172.32.0.1', '100.128.0.1', '2001:db8::1', - '::ffff:808:808', '2002:808:808::', '64:ff9b::808:808', - ]; - for (const host of disallowed) { - assert.equal(hook.isDisallowedHost(host), true, host); - } - for (const host of allowed) { - assert.equal(hook.isDisallowedHost(host), false, host); - } -}); - -test('localhost names are rejected with or without a trailing dot', () => { - for (const host of ['localhost.', 'LOCALHOST.', 'foo.localhost', 'a.b.localhost.', '127.0.0.1.']) { - assert.equal(hook.resolveEndpoint({ KONDUCTOR_METRICS_ENDPOINT: `https://${host}/in` }), null, host); - } - assert.notEqual(hook.resolveEndpoint({ KONDUCTOR_METRICS_ENDPOINT: 'https://notlocalhost.example/in' }), null); -}); - -function lookupWith(answer, options = {}) { - const fakeDns = (hostname, opts, callback) => { - assert.equal(opts.all, true); - if (answer !== 'hang') { - setImmediate(() => callback(null, answer.map((address) => ({ address, family: address.includes(':') ? 6 : 4 })))); - } - }; - const lookup = hook.checkedLookup(fakeDns, 50); - return new Promise((resolve) => { - lookup('collector.example.com', options, (err, address, family) => resolve({ err, address, family })); - }); -} - -test('the connection lookup rejects any resolved address on the denylist', async () => { - for (const answer of [['127.0.0.1'], ['10.1.2.3'], ['::1'], ['8.8.8.8', '192.168.0.1'], ['::ffff:169.254.169.254'], []]) { - const { err } = await lookupWith(answer); - assert.ok(err instanceof Error, answer.join(',')); - } -}); - -test('the connection lookup pins the checked address in both callback shapes', async () => { - const single = await lookupWith(['8.8.8.8', '2001:4860:4860::8888']); - assert.equal(single.err, null); - assert.equal(single.address, '8.8.8.8'); - assert.equal(single.family, 4); - const all = await lookupWith(['2001:4860:4860::8888'], { all: true }); - assert.equal(all.err, null); - assert.deepEqual(all.address, [{ address: '2001:4860:4860::8888', family: 6 }]); -}); - -test('the connection lookup fails closed when DNS does not answer in time', async () => { - const started = Date.now(); - const { err } = await lookupWith('hang'); - assert.ok(err instanceof Error); - assert.ok(Date.now() - started < 1000); -}); - -test('a hostname that resolves to a private address sends nothing', (t) => { - for (const address of ['127.0.0.1', '10.0.0.7', '::1']) { - const { result, requests } = runHook(t, { ...AGENT_EVENT, env: { TELEMETRY_STUB_DNS: address } }); - assertSilent(result); - assert.equal(requests.length, 0, address); - } - const { requests } = runHook(t, { ...AGENT_EVENT, env: { TELEMETRY_STUB_DNS: '8.8.8.8' } }); - assert.equal(requests.length, 1); - assert.equal(requests[0].pinned, '8.8.8.8'); -}); - -test('the local-endpoint escape hatch skips the lookup check', (t) => { - const { requests } = runHook(t, { - ...AGENT_EVENT, - env: { TELEMETRY_STUB_DNS: '127.0.0.1', KONDUCTOR_TELEMETRY_ALLOW_LOCAL_ENDPOINT: '1' }, - }); - assert.equal(requests.length, 1); - assert.equal(requests[0].hasLookup, false); -}); - -// ── Bad input ──────────────────────────────────────────────────────────── - test('bad input prints nothing and exits 0', (t) => { const cases = [ { eventType: 'agent-invocation', stdin: 'not json at all' }, diff --git a/tests/scripts/claude-plugin-telemetry-https-stub.js b/tests/scripts/claude-plugin-telemetry-https-stub.js index 71446c48..a40ad44d 100644 --- a/tests/scripts/claude-plugin-telemetry-https-stub.js +++ b/tests/scripts/claude-plugin-telemetry-https-stub.js @@ -2,44 +2,25 @@ // Preloaded with `node --require` by claude-plugin-telemetry-hook.test.js. // Replaces https.request so the hook never reaches the network, and appends // each request it would have sent to $TELEMETRY_STUB_OUT as one JSON line. -// -// With $TELEMETRY_STUB_DNS set (comma-separated addresses), dns.lookup -// answers with those addresses and the stub runs the request's `lookup` -// option the way a socket would. A lookup error means nothing is recorded. 'use strict'; -const dns = require('node:dns'); const fs = require('node:fs'); const https = require('node:https'); -const net = require('node:net'); const { EventEmitter } = require('node:events'); -const stubAddresses = process.env.TELEMETRY_STUB_DNS; -if (stubAddresses !== undefined) { - dns.lookup = (hostname, options, callback) => { - const addresses = stubAddresses - .split(',') - .filter(Boolean) - .map((address) => ({ address, family: net.isIPv6(address) ? 6 : 4 })); - setImmediate(() => callback(null, addresses)); - }; -} - https.request = (url, options) => { const request = new EventEmitter(); const chunks = []; request.setTimeout = () => request; request.destroy = () => request.emit('close'); request.write = (chunk) => chunks.push(Buffer.from(chunk)); - const record = (pinned) => { + const record = () => { fs.appendFileSync( process.env.TELEMETRY_STUB_OUT, `${JSON.stringify({ url: String(url), method: options.method, headers: options.headers, - hasLookup: typeof options.lookup === 'function', - pinned, body: Buffer.concat(chunks).toString('utf8'), })}\n`, ); @@ -53,17 +34,7 @@ https.request = (url, options) => { if (chunk) { chunks.push(Buffer.from(chunk)); } - if (stubAddresses === undefined || typeof options.lookup !== 'function') { - record(null); - return; - } - options.lookup(new URL(String(url)).hostname, { all: false }, (err, address) => { - if (err) { - request.emit('error', err); - return; - } - record(address); - }); + record(); }; return request; }; From 88f54e94219d585825674154f2ac1975b8804c41 Mon Sep 17 00:00:00 2001 From: Nihit Kasabwala Date: Tue, 20 Oct 2026 18:15:49 +0000 Subject: [PATCH 09/10] fix(plugin): bound the hook's file reads and fail closed on unclear configs - Read config.yml, telemetry.json and plugin.json with a non-blocking open, regular files only, capped at 64 KiB. A FIFO no longer hangs the hook, and a non-regular or oversized file counts as unreadable. - Check the agent filter before any config.yml read, so non-Konductor sessions read no config files. Every opt-out is still checked before the machine record is created. - Treat config.yml as off when it mentions both telemetry and enabled but has no plain top-level telemetry key, and accept content on a leading `---` line. A telemetry block without enabled still means on. - Add a 10 second timeout to both hooks.json handlers. - Test the send timeout and the stdin backstop with a stub that never responds and a stdin that never closes, and test that the endpoint ignores KONDUCTOR_METRICS_ENDPOINT and telemetry.endpoint. - Narrow the hook header to the wire format, rename a stale test section, and say in the schema that plugin events carry the plugin version. --- docs/telemetry-schema.json | 2 +- scripts/claude-plugin-telemetry-hook.js | 89 ++++++---- scripts/render-claude-plugin-hooks-json.py | 3 + .../claude-plugin-telemetry-hook.test.js | 154 +++++++++++++++++- .../claude-plugin-telemetry-https-stub.js | 12 +- ...semble_claude_plugin_branch_mcp_rewrite.py | 1 + 6 files changed, 223 insertions(+), 38 deletions(-) diff --git a/docs/telemetry-schema.json b/docs/telemetry-schema.json index 6712c3cc..3d4ddb9d 100644 --- a/docs/telemetry-schema.json +++ b/docs/telemetry-schema.json @@ -72,7 +72,7 @@ "string", "null" ], - "description": "The installed CONTENT's own version (distinct from the outer envelope's Version, which is always the CLI binary's own env!(\"CARGO_PKG_VERSION\")) -- populated only on package_installed and package_version_updated, sourced from that install's /.konductor/install-info.json record. Null on every other eventType (agent_invocation, subagent_invocation, mcp_tool_call, cli_error, package_uninstalled), and also null on package_installed/package_version_updated when the install source predates the VERSION-in-dist/ convention or that file could not be read -- never a fabricated version string." + "description": "The installed CONTENT's own version (distinct from the outer envelope's Version, which is the CLI binary's own env!(\"CARGO_PKG_VERSION\") on CLI events and the plugin manifest version on Claude Code plugin events) -- populated only on package_installed and package_version_updated, sourced from that install's /.konductor/install-info.json record. Null on every other eventType (agent_invocation, subagent_invocation, mcp_tool_call, cli_error, package_uninstalled), and also null on package_installed/package_version_updated when the install source predates the VERSION-in-dist/ convention or that file could not be read -- never a fabricated version string." }, "TimeStamp": { "type": "string", diff --git a/scripts/claude-plugin-telemetry-hook.js b/scripts/claude-plugin-telemetry-hook.js index be631515..674aecfc 100644 --- a/scripts/claude-plugin-telemetry-hook.js +++ b/scripts/claude-plugin-telemetry-hook.js @@ -12,10 +12,9 @@ // It reads the hook payload from stdin, prints nothing, and always exits 0. // SessionStart stdout becomes model context, so any output would be a bug. // -// Wire format and rules mirror cli/konductor-rs/src/cli/telemetry_hook.rs, -// cli/konductor-rs/src/cli/telemetry/{report,envelope}.rs and -// shared/konductor-telemetry/src/identity.rs, so plugin and CLI events line -// up and share one machine identity in ~/.konductor/telemetry.json. +// The wire format matches cli/konductor-rs/src/cli/telemetry/envelope.rs, so +// plugin and CLI events line up. Both use the machine identity in +// ~/.konductor/telemetry.json. 'use strict'; @@ -32,6 +31,7 @@ const NIL_UUID_SENTINEL = '0'.repeat(64); const HARNESS = 'claude-plugin'; const INSTANCE_SCHEMA_VERSION = 1; const MAX_HOOK_PAYLOAD_BYTES = 64 * 1024; +const MAX_FILE_BYTES = 64 * 1024; const SEND_TIMEOUT_MS = 3000; const AGENT_INVOCATION = 'agent-invocation'; const SUBAGENT_INVOCATION = 'subagent-invocation'; @@ -51,6 +51,37 @@ function nonEmptyString(value) { return typeof value === 'string' && value !== '' ? value : null; } +// Returns the text of a regular file of at most MAX_FILE_BYTES, else null. +// O_NONBLOCK stops a FIFO from blocking the open, which would also stall the +// backstop timer. +function readSmallFile(file) { + let fd; + try { + fd = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NONBLOCK || 0)); + } catch { + return null; + } + try { + if (!fs.fstatSync(fd).isFile()) { + return null; + } + const buffer = Buffer.alloc(MAX_FILE_BYTES + 1); + let size = 0; + while (size < buffer.length) { + const read = fs.readSync(fd, buffer, size, buffer.length - size, null); + if (read === 0) { + break; + } + size += read; + } + return size > MAX_FILE_BYTES ? null : buffer.toString('utf8', 0, size); + } catch { + return null; + } finally { + fs.closeSync(fd); + } +} + function isoMillis(date) { return date.toISOString(); } @@ -201,7 +232,8 @@ function parseFlow(text) { const KEY_LINE = /^(\s*)(?:"([^"]*)"|'([^']*)'|([^\s'"{[#][^:]*?))\s*:(?:\s+(.*))?$/; // `telemetry.enabled` from the top level of a block mapping: true, false, -// or undefined when it can't be read with confidence. +// null when the section has no `enabled`, or undefined when it can't be +// read with confidence. function blockEnabled(lines) { let section = null; for (let index = 0; index < lines.length; index++) { @@ -254,7 +286,10 @@ function blockEnabled(lines) { section.values.push(TRUE_SCALARS.includes(rest) ? true : FALSE_SCALARS.includes(rest) ? false : rest); } } - if (section === null || section.values.length === 0) { + if (section === null) { + return undefined; + } + if (section.values.length === 0) { return null; } return section.values.length === 1 && typeof section.values[0] === 'boolean' ? section.values[0] : undefined; @@ -270,11 +305,13 @@ function balanced(text) { // Reads `telemetry.enabled` from .konductor/config.yml. A missing setting // leaves telemetry on, as in the CLI. Fails closed: if `enabled` is set but -// not plainly to true, telemetry is treated as off. YAML tags, anchors, -// aliases and merge keys are never interpreted. +// not plainly to true, or the file mentions both words but has no plain +// top-level `telemetry` key, telemetry is treated as off. YAML tags, +// anchors, aliases and merge keys are never interpreted. function configDisablesTelemetry(rawText) { const lines = rawText .replace(/^\uFEFF/, '') + .replace(/^---[ \t]+(?=\S)/, '') .split(/\r?\n/) .map((line) => stripComment(line).replace(/\s+$/, '')) .filter((line) => line.trim() !== '' && !/^(---|\.\.\.)$/.test(line)); @@ -292,9 +329,6 @@ function configDisablesTelemetry(rawText) { return true; } const section = value.telemetry; - if (section === undefined) { - return false; - } if (section === null || typeof section !== 'object' || Array.isArray(section)) { return true; } @@ -320,16 +354,12 @@ function configPaths(cwd, home) { } // Nearest first, as the CLI walks install roots, then $HOME. An unreadable -// file is skipped, as the CLI's read_telemetry_config does. +// file is skipped, as the CLI's read_telemetry_config does. A non-regular or +// oversized file counts as unreadable. function telemetryOffByConfig(cwd, home) { return configPaths(cwd, home).some((file) => { - let text; - try { - text = fs.readFileSync(file, 'utf8'); - } catch { - return false; - } - return configDisablesTelemetry(text); + const text = readSmallFile(file); + return text !== null && configDisablesTelemetry(text); }); } @@ -361,7 +391,7 @@ function instancePath(home) { function readInstanceRaw(home) { let value; try { - value = JSON.parse(fs.readFileSync(instancePath(home), 'utf8')); + value = JSON.parse(readSmallFile(instancePath(home))); } catch { return { kind: 'absent' }; } @@ -498,9 +528,7 @@ function ensureInstance(home, now = new Date()) { function readPluginManifest(pluginRoot) { try { - const manifest = JSON.parse( - fs.readFileSync(path.join(pluginRoot, '.claude-plugin', 'plugin.json'), 'utf8'), - ); + const manifest = JSON.parse(readSmallFile(path.join(pluginRoot, '.claude-plugin', 'plugin.json'))); const version = nonEmptyString(manifest.version); if (version === null) { return null; @@ -635,15 +663,9 @@ async function run(argv, env) { return; } - if (telemetryOffByEnv(env)) { - return; - } - const home = homeDir(env); - const cwd = nonEmptyString(payload.cwd) || process.cwd(); - if (telemetryOffByConfig(cwd, home) || telemetryOffByPluginOption(env)) { + if (telemetryOffByEnv(env) || telemetryOffByPluginOption(env)) { return; } - const manifest = readPluginManifest(PLUGIN_ROOT); if (manifest === null) { return; @@ -658,9 +680,15 @@ async function run(argv, env) { if (invocation === null) { return; } + const home = homeDir(env); if (home === null) { return; } + const cwd = nonEmptyString(payload.cwd) || process.cwd(); + if (telemetryOffByConfig(cwd, home)) { + return; + } + const record = ensureInstance(home); if (!record.telemetry_consent) { return; @@ -687,6 +715,7 @@ module.exports = { INSTANCE_SCHEMA_VERSION, MAX_HOOK_PAYLOAD_BYTES, NIL_UUID_SENTINEL, + SEND_TIMEOUT_MS, SOLUTION_ID, buildEventBody, configDisablesTelemetry, diff --git a/scripts/render-claude-plugin-hooks-json.py b/scripts/render-claude-plugin-hooks-json.py index aa77c3f1..bee07a62 100755 --- a/scripts/render-claude-plugin-hooks-json.py +++ b/scripts/render-claude-plugin-hooks-json.py @@ -54,6 +54,9 @@ def command(event_type: str) -> dict: "type": "command", "command": f'node "${{CLAUDE_PLUGIN_ROOT}}/{HOOK_SCRIPT}" {event_type}', "async": True, + # Seconds. Claude Code doesn't enforce it on async hooks, so it only + # applies if async is dropped; the hook exits within about 5 anyway. + "timeout": 10, } diff --git a/tests/scripts/claude-plugin-telemetry-hook.test.js b/tests/scripts/claude-plugin-telemetry-hook.test.js index 62ae6017..e99347de 100644 --- a/tests/scripts/claude-plugin-telemetry-hook.test.js +++ b/tests/scripts/claude-plugin-telemetry-hook.test.js @@ -10,7 +10,8 @@ const test = require('node:test'); const assert = require('node:assert/strict'); -const { spawnSync } = require('node:child_process'); +const { spawn, spawnSync } = require('node:child_process'); +const { once } = require('node:events'); const fs = require('node:fs'); const os = require('node:os'); const path = require('node:path'); @@ -55,24 +56,30 @@ function makePluginTree(t) { // Runs the hook once. `env` is the whole environment apart from PATH, HOME // and the stub's output file, so the developer's own settings never leak in. -function runHook(t, { eventType, payload, env = {}, home, stdin }) { +function runHook(t, { eventType, payload, env = {}, home, stdin, preload = [], setupPlugin, timeout = 15000 }) { const plugin = makePluginTree(t); + if (setupPlugin) { + setupPlugin(plugin); + } const homeDir = home || scratchDir(t, 'home'); const project = scratchDir(t, 'project'); const stubOut = path.join(scratchDir(t, 'stub'), 'requests.jsonl'); const input = stdin !== undefined ? stdin : JSON.stringify({ cwd: project, session_id: RAW_SESSION_ID, ...payload }); + const requires = [STUB, ...preload].flatMap((file) => ['--require', file]); + const started = Date.now(); const result = spawnSync( process.execPath, - ['--require', STUB, path.join(plugin, 'hooks', 'konductor-telemetry.js'), ...(eventType ? [eventType] : [])], + [...requires, path.join(plugin, 'hooks', 'konductor-telemetry.js'), ...(eventType ? [eventType] : [])], { input, cwd: project, env: { PATH: process.env.PATH, HOME: homeDir, TELEMETRY_STUB_OUT: stubOut, ...env }, encoding: 'utf8', - timeout: 15000, + timeout, }, ); + const elapsed = Date.now() - started; const requests = fs.existsSync(stubOut) ? fs .readFileSync(stubOut, 'utf8') @@ -80,7 +87,7 @@ function runHook(t, { eventType, payload, env = {}, home, stdin }) { .split('\n') .map((line) => JSON.parse(line)) : []; - return { result, requests, home: homeDir, project }; + return { result, requests, home: homeDir, project, elapsed }; } function recordPath(home) { @@ -397,6 +404,11 @@ test('config parser treats telemetry.enabled: false as off in every YAML form', 'quoted scalar': 'telemetry:\n enabled: "false"\n', 'duplicate key': 'telemetry:\n enabled: true\n enabled: false\n', 'unclosed flow': 'telemetry: {enabled: true\n', + 'flow after ---': '--- {telemetry: {enabled: false}}\n', + 'uniform root indent': ' telemetry:\n enabled: false\n', + 'indented after ---': '--- \n telemetry:\n enabled: false\n', + 'indented JSON': ' {\n "telemetry": {\n "enabled": false\n }\n}\n', + 'complex key': '? telemetry\n: enabled: false\n', }; const on = { block: 'telemetry:\n enabled: true\n', @@ -409,6 +421,8 @@ test('config parser treats telemetry.enabled: false as off in every YAML form', 'other section': 'other:\n enabled: false\n', 'nested enabled': 'telemetry:\n nested:\n enabled: false\n', 'commented out': '# telemetry:\n# enabled: false\n', + 'after ---': '--- \ntelemetry:\n enabled: true\n', + 'flow after ---': '--- {telemetry: {enabled: true}}\n', empty: '', }; for (const [name, text] of Object.entries(off)) { @@ -419,6 +433,99 @@ test('config parser treats telemetry.enabled: false as off in every YAML form', } }); +// ── File reads ─────────────────────────────────────────────────────────── + +const POSIX_ONLY = { skip: process.platform === 'win32' }; + +function mkfifo(file) { + fs.mkdirSync(path.dirname(file), { recursive: true }); + const made = spawnSync('mkfifo', [file]); + assert.equal(made.status, 0, 'mkfifo failed'); +} + +test('a FIFO config.yml is skipped without blocking the hook', POSIX_ONLY, (t) => { + const project = scratchDir(t, 'fifo-config'); + mkfifo(path.join(project, '.konductor', 'config.yml')); + const { result, requests, elapsed } = runHook(t, { ...agentEventIn(project), timeout: 8000 }); + assertSilent(result); + assert.ok(elapsed < 3000, `took ${elapsed} ms`); + assert.equal(requests.length, 1); +}); + +test('a FIFO telemetry.json or plugin.json exits promptly and sends nothing', POSIX_ONLY, (t) => { + const fifoHome = scratchDir(t, 'fifo-home'); + mkfifo(recordPath(fifoHome)); + const fifoManifest = (plugin) => { + const manifest = path.join(plugin, '.claude-plugin', 'plugin.json'); + fs.rmSync(manifest); + mkfifo(manifest); + }; + for (const options of [{ home: fifoHome }, { setupPlugin: fifoManifest }]) { + const { result, requests, elapsed } = runHook(t, { ...AGENT_EVENT, ...options, timeout: 8000 }); + assertSilent(result); + assert.ok(elapsed < 3000, `took ${elapsed} ms`); + assert.equal(requests.length, 0); + } +}); + +test('a config.yml over 64 KiB is treated as unreadable', (t) => { + const optOut = 'telemetry:\n enabled: false\n'; + const atCap = scratchDir(t, 'config-at-cap'); + writeConfig(atCap, optOut + '#'.repeat(64 * 1024 - optOut.length - 1) + '\n'); + assert.equal(runHook(t, agentEventIn(atCap)).requests.length, 0); + const overCap = scratchDir(t, 'config-over-cap'); + writeConfig(overCap, optOut + '#'.repeat(64 * 1024 - optOut.length) + '\n'); + assert.equal(runHook(t, agentEventIn(overCap)).requests.length, 1); +}); + +test('an oversized telemetry.json fails closed and is left alone', (t) => { + const home = scratchDir(t, 'big-record-home'); + const text = writeRecord(home, { + schema_version: 1, + UUID: VALID_UUID, + created_at: '2026-01-01T00:00:00.000Z', + telemetry_consent: true, + pad: 'x'.repeat(64 * 1024), + }); + const { result, requests } = runHook(t, { ...AGENT_EVENT, home }); + assertSilent(result); + assert.equal(requests.length, 0); + assert.equal(fs.readFileSync(recordPath(home), 'utf8'), text); +}); + +test('a non-Konductor session opens no config files', (t) => { + const traceDir = scratchDir(t, 'trace'); + const traceOut = path.join(traceDir, 'opened.txt'); + const tracer = path.join(traceDir, 'trace-config-reads.js'); + fs.writeFileSync( + tracer, + `'use strict'; +const fs = require('node:fs'); +for (const name of ['openSync', 'readFileSync', 'statSync', 'existsSync']) { + const original = fs[name]; + fs[name] = function (file, ...rest) { + if (String(file).endsWith('config.yml')) { + fs.appendFileSync(${JSON.stringify(traceOut)}, String(file) + '\\n'); + } + return original.call(this, file, ...rest); + }; +} +`, + ); + const project = scratchDir(t, 'traced-project'); + writeConfig(project, 'telemetry:\n enabled: true\n'); + const opened = () => (fs.existsSync(traceOut) ? fs.readFileSync(traceOut, 'utf8') : ''); + const stdin = (agentType) => JSON.stringify({ cwd: project, session_id: RAW_SESSION_ID, agent_type: agentType }); + + const other = runHook(t, { eventType: 'agent-invocation', stdin: stdin('Explore'), preload: [tracer] }); + assertSilent(other.result); + assert.equal(opened(), ''); + + const ours = runHook(t, { eventType: 'agent-invocation', stdin: stdin('konductor:konductor'), preload: [tracer] }); + assert.equal(ours.requests.length, 1); + assert.notEqual(opened(), '', 'the tracer saw no reads at all'); +}); + // ── Identity ───────────────────────────────────────────────────────────── test('creates the machine record with mode 0600 when missing', (t) => { @@ -520,7 +627,7 @@ test('a write that makes no progress fails closed and publishes nothing', (t) => assert.deepEqual(fs.readdirSync(path.join(home, '.konductor')), []); }); -// ── Endpoint ───────────────────────────────────────────────────────────── +// ── Failure handling ───────────────────────────────────────────────────── test('bad input prints nothing and exits 0', (t) => { const cases = [ @@ -548,6 +655,40 @@ test('an unwritable HOME prints nothing and exits 0', (t) => { assert.equal(requests.length, 0); }); +test('a request that never responds is dropped after SEND_TIMEOUT_MS', (t) => { + const { result, requests, elapsed } = runHook(t, { ...AGENT_EVENT, env: { TELEMETRY_STUB_MODE: 'hang' } }); + assertSilent(result); + assert.equal(requests.length, 1); + assert.ok(elapsed >= hook.SEND_TIMEOUT_MS - 200 && elapsed < hook.SEND_TIMEOUT_MS + 1500, `took ${elapsed} ms`); +}); + +test('stdin that never closes still exits 0 through the backstop', async (t) => { + const plugin = makePluginTree(t); + const stubOut = path.join(scratchDir(t, 'stub'), 'requests.jsonl'); + const started = Date.now(); + const child = spawn(process.execPath, ['--require', STUB, path.join(plugin, 'hooks', 'konductor-telemetry.js'), 'agent-invocation'], { + cwd: scratchDir(t, 'project'), + env: { PATH: process.env.PATH, HOME: scratchDir(t, 'home'), TELEMETRY_STUB_OUT: stubOut }, + stdio: ['pipe', 'ignore', 'ignore'], + }); + const killer = setTimeout(() => child.kill('SIGKILL'), 10000); + const [code] = await once(child, 'exit'); + clearTimeout(killer); + child.stdin.destroy(); + const elapsed = Date.now() - started; + assert.equal(code, 0); + assert.ok(elapsed < hook.SEND_TIMEOUT_MS + 3500, `took ${elapsed} ms`); + assert.equal(fs.existsSync(stubOut), false); +}); + +test('the endpoint is fixed: KONDUCTOR_METRICS_ENDPOINT and telemetry.endpoint are ignored', (t) => { + const project = scratchDir(t, 'endpoint-config'); + writeConfig(project, 'telemetry:\n enabled: true\n endpoint: https://config.example/generic\n'); + const { requests } = runHook(t, { ...agentEventIn(project), env: { KONDUCTOR_METRICS_ENDPOINT: 'https://x.example/' } }); + assert.equal(requests.length, 1); + assert.equal(requests[0].url, hook.DEFAULT_TELEMETRY_ENDPOINT); +}); + // ── hooks.json ─────────────────────────────────────────────────────────── test('rendered hooks.json wires both events and matches every plugin agent', (t) => { @@ -566,6 +707,7 @@ test('rendered hooks.json wires both events and matches every plugin agent', (t) const [handler] = hooks[event][0].hooks; assert.equal(handler.type, 'command'); assert.equal(handler.async, true); + assert.equal(handler.timeout, 10); assert.equal(handler.command, `node "\${CLAUDE_PLUGIN_ROOT}/hooks/konductor-telemetry.js" ${arg}`); } const matcher = new RegExp(hooks.SubagentStart[0].matcher, 'u'); diff --git a/tests/scripts/claude-plugin-telemetry-https-stub.js b/tests/scripts/claude-plugin-telemetry-https-stub.js index a40ad44d..44886eab 100644 --- a/tests/scripts/claude-plugin-telemetry-https-stub.js +++ b/tests/scripts/claude-plugin-telemetry-https-stub.js @@ -2,6 +2,8 @@ // Preloaded with `node --require` by claude-plugin-telemetry-hook.test.js. // Replaces https.request so the hook never reaches the network, and appends // each request it would have sent to $TELEMETRY_STUB_OUT as one JSON line. +// With TELEMETRY_STUB_MODE=hang the request never gets a response, and an open +// timer stands in for the socket until the hook destroys the request. 'use strict'; const fs = require('node:fs'); @@ -11,8 +13,12 @@ const { EventEmitter } = require('node:events'); https.request = (url, options) => { const request = new EventEmitter(); const chunks = []; + let socket = null; request.setTimeout = () => request; - request.destroy = () => request.emit('close'); + request.destroy = () => { + clearInterval(socket); + request.emit('close'); + }; request.write = (chunk) => chunks.push(Buffer.from(chunk)); const record = () => { fs.appendFileSync( @@ -24,6 +30,10 @@ https.request = (url, options) => { body: Buffer.concat(chunks).toString('utf8'), })}\n`, ); + if (process.env.TELEMETRY_STUB_MODE === 'hang') { + socket = setInterval(() => {}, 1000); + return; + } setImmediate(() => { const response = new EventEmitter(); response.resume = () => setImmediate(() => response.emit('end')); diff --git a/tests/test_assemble_claude_plugin_branch_mcp_rewrite.py b/tests/test_assemble_claude_plugin_branch_mcp_rewrite.py index 710720a7..d6be7734 100644 --- a/tests/test_assemble_claude_plugin_branch_mcp_rewrite.py +++ b/tests/test_assemble_claude_plugin_branch_mcp_rewrite.py @@ -145,6 +145,7 @@ def test_assembled_tree_ships_the_telemetry_hook(assembled): for event in ("SessionStart", "SubagentStart"): handler = hooks[event][0]["hooks"][0] assert handler["async"] is True + assert handler["timeout"] == 10 assert '"${CLAUDE_PLUGIN_ROOT}/hooks/konductor-telemetry.js"' in handler["command"] plugin = json.loads((assembled / ".claude-plugin" / "plugin.json").read_text(encoding="utf-8")) From bdd28fae7971df106752e0ea3c7b3d325923537a Mon Sep 17 00:00:00 2001 From: Nihit Kasabwala Date: Tue, 20 Oct 2026 18:19:18 +0000 Subject: [PATCH 10/10] fix(plugin): drop the hooks.json timeout, which async hooks ignore Claude Code doesn't enforce timeout on async command hooks, so the field had no effect. The hook's own send timeout and backstop bound its run time. --- scripts/render-claude-plugin-hooks-json.py | 3 --- tests/scripts/claude-plugin-telemetry-hook.test.js | 1 - tests/test_assemble_claude_plugin_branch_mcp_rewrite.py | 1 - 3 files changed, 5 deletions(-) diff --git a/scripts/render-claude-plugin-hooks-json.py b/scripts/render-claude-plugin-hooks-json.py index bee07a62..aa77c3f1 100755 --- a/scripts/render-claude-plugin-hooks-json.py +++ b/scripts/render-claude-plugin-hooks-json.py @@ -54,9 +54,6 @@ def command(event_type: str) -> dict: "type": "command", "command": f'node "${{CLAUDE_PLUGIN_ROOT}}/{HOOK_SCRIPT}" {event_type}', "async": True, - # Seconds. Claude Code doesn't enforce it on async hooks, so it only - # applies if async is dropped; the hook exits within about 5 anyway. - "timeout": 10, } diff --git a/tests/scripts/claude-plugin-telemetry-hook.test.js b/tests/scripts/claude-plugin-telemetry-hook.test.js index e99347de..2298ecd7 100644 --- a/tests/scripts/claude-plugin-telemetry-hook.test.js +++ b/tests/scripts/claude-plugin-telemetry-hook.test.js @@ -707,7 +707,6 @@ test('rendered hooks.json wires both events and matches every plugin agent', (t) const [handler] = hooks[event][0].hooks; assert.equal(handler.type, 'command'); assert.equal(handler.async, true); - assert.equal(handler.timeout, 10); assert.equal(handler.command, `node "\${CLAUDE_PLUGIN_ROOT}/hooks/konductor-telemetry.js" ${arg}`); } const matcher = new RegExp(hooks.SubagentStart[0].matcher, 'u'); diff --git a/tests/test_assemble_claude_plugin_branch_mcp_rewrite.py b/tests/test_assemble_claude_plugin_branch_mcp_rewrite.py index d6be7734..710720a7 100644 --- a/tests/test_assemble_claude_plugin_branch_mcp_rewrite.py +++ b/tests/test_assemble_claude_plugin_branch_mcp_rewrite.py @@ -145,7 +145,6 @@ def test_assembled_tree_ships_the_telemetry_hook(assembled): for event in ("SessionStart", "SubagentStart"): handler = hooks[event][0]["hooks"][0] assert handler["async"] is True - assert handler["timeout"] == 10 assert '"${CLAUDE_PLUGIN_ROOT}/hooks/konductor-telemetry.js"' in handler["command"] plugin = json.loads((assembled / ".claude-plugin" / "plugin.json").read_text(encoding="utf-8"))