diff --git a/lib/exporters/field-allowlist.mjs b/lib/exporters/field-allowlist.mjs index d5226e0..c86bd46 100644 --- a/lib/exporters/field-allowlist.mjs +++ b/lib/exporters/field-allowlist.mjs @@ -76,6 +76,16 @@ const TREKCONTINUE_ALLOWED = Object.freeze(new Set([ 'ts', 'next_session_label', 'status', 'profile', 'profile_source', ])); +// Source: tests/fixtures/jsonl-schemas.md row 9 (token-usage — SKAL-2) +// CWE-212: numeric + low-cardinality-label fields ONLY. DENY BY OMISSION: +// session_id (UUID), transcript_path (filesystem path), cwd (filesystem path) +// are written into the jsonl for upsert keying but MUST NOT reach the exporter. +const TOKEN_USAGE_ALLOWED = Object.freeze(new Set([ + 'ts', 'scope', 'model', + 'tokens_input', 'tokens_output', 'tokens_cache_creation', 'tokens_cache_read', + 'cost_usd', 'is_estimate', 'price_table_version', +])); + // Schema-id → allowlist set const SCHEMA_ALLOWLISTS = Object.freeze({ 'trekbrief': TREKBRIEF_ALLOWED, @@ -87,6 +97,7 @@ const SCHEMA_ALLOWLISTS = Object.freeze({ 'post_bash_stats': POST_BASH_STATS_ALLOWED, // common alt-spelling 'trekreview': TREKREVIEW_ALLOWED, 'trekcontinue': TREKCONTINUE_ALLOWED, + 'token-usage': TOKEN_USAGE_ALLOWED, }); /** @@ -135,4 +146,5 @@ export { TREKEXECUTE_ALLOWED, TREKREVIEW_ALLOWED, TREKCONTINUE_ALLOWED, + TOKEN_USAGE_ALLOWED, }; diff --git a/tests/hooks/otel-export-textfile.test.mjs b/tests/hooks/otel-export-textfile.test.mjs index 3025d94..85d8355 100644 --- a/tests/hooks/otel-export-textfile.test.mjs +++ b/tests/hooks/otel-export-textfile.test.mjs @@ -11,6 +11,8 @@ import { readFileSync } from 'node:fs'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import { transformToPrometheus, normalizeMetricName } from '../../lib/exporters/textfile-format.mjs'; +import { transformToOtlpJson } from '../../lib/exporters/otlp-format.mjs'; +import { applyFieldAllowlist } from '../../lib/exporters/field-allowlist.mjs'; const __dirname = dirname(fileURLToPath(import.meta.url)); const FIXTURES = join(__dirname, '..', 'fixtures'); @@ -74,6 +76,38 @@ test('normalizeMetricName: dots/dashes/spaces → underscore, lowercase, voyage_ assert.equal(normalizeMetricName('METRIC NAME'), 'voyage_metric_name'); }); +test('SC#6: allowlisted token-usage record → Prometheus + OTLP metrics; PII absent (end-to-end)', () => { + // Raw record as written to token-usage-stats.jsonl (carries PII for upsert keying). + const raw = { + session_id: 'x', + transcript_path: '/p', + cwd: '/c', + ts: '2026-06-26T12:00:00.000Z', + scope: 'main-context', + model: 'claude-opus-4-8', + tokens_input: 12345, + tokens_output: 6789, + cost_usd: 0.42, + is_estimate: false, + price_table_version: '2026-06-26', + }; + const rec = applyFieldAllowlist(raw, 'token-usage'); + + // Prometheus: numeric fields auto-promote to voyage_token_usage_* metrics. + const prom = transformToPrometheus([rec]); + assert.match(prom, /voyage_token_usage_tokens_input\b/); + assert.match(prom, /voyage_token_usage_cost_usd\b/); + // CWE-212: PII must never appear in exporter output. + assert.ok(!prom.includes('session_id'), 'session_id leaked into Prometheus output'); + assert.ok(!prom.includes('transcript_path'), 'transcript_path leaked into Prometheus output'); + assert.ok(!prom.includes('/p'), 'transcript_path value leaked into Prometheus output'); + + // OTLP parity: dot-separated, dash-preserved naming (NOT the Prometheus underscore form). + const otlp = JSON.stringify(transformToOtlpJson([rec])); + assert.ok(otlp.includes('voyage.token-usage.tokens_input'), 'OTLP token metric missing'); + assert.ok(!otlp.includes('transcript_path'), 'transcript_path leaked into OTLP output'); +}); + test('determinism: identical input produces identical output (sorted keys)', () => { const records = loadJsonl('stats-sample.jsonl'); const out1 = transformToPrometheus(records); diff --git a/tests/hooks/otel-export-validators.test.mjs b/tests/hooks/otel-export-validators.test.mjs index a7cf8e5..6aee4be 100644 --- a/tests/hooks/otel-export-validators.test.mjs +++ b/tests/hooks/otel-export-validators.test.mjs @@ -13,6 +13,7 @@ import { applyFieldAllowlist, POST_BASH_STATS_ALLOWED, EVENT_EMIT_PAYLOAD_ALLOWED, + TOKEN_USAGE_ALLOWED, } from '../../lib/exporters/field-allowlist.mjs'; // ---- path-validator: CWE-22 mitigation ------------------------------------- @@ -241,6 +242,40 @@ test('field-allowlist: Object.freeze on allowlists (drift-pin)', () => { assert.equal(Object.isFrozen(POST_BASH_STATS_ALLOWED), true, 'POST_BASH_STATS_ALLOWED must be frozen — runtime mutation prevention'); assert.equal(Object.isFrozen(EVENT_EMIT_PAYLOAD_ALLOWED), true); + assert.equal(Object.isFrozen(TOKEN_USAGE_ALLOWED), true, + 'TOKEN_USAGE_ALLOWED must be frozen — runtime mutation prevention'); +}); + +// ---- token-usage allowlist (SKAL-2, CWE-212) ------------------------------- + +test('field-allowlist: token-usage INCLUDES numeric/label fields, EXCLUDES session_id/transcript_path/cwd (two-sided)', () => { + const record = { + ts: '2026-06-26T12:00:00.000Z', + session_id: 'uuid-secret', + transcript_path: '/Users/ktg/.claude/projects/x/sesn.jsonl', + cwd: '/Users/ktg/secret/project', + scope: 'main-context', + model: 'claude-opus-4-8', + tokens_input: 12345, + tokens_output: 6789, + tokens_cache_creation: 400, + tokens_cache_read: 5000, + cost_usd: 0.42, + is_estimate: false, + price_table_version: '2026-06-26', + }; + const out = applyFieldAllowlist(record, 'token-usage'); + // INCLUDED (numeric + low-cardinality labels) + for (const k of ['tokens_input', 'tokens_output', 'tokens_cache_creation', + 'tokens_cache_read', 'cost_usd', 'is_estimate', 'price_table_version', 'scope', 'model']) { + assert.equal(k in out, true, `${k} MUST be allowlisted`); + } + assert.equal(out.tokens_input, 12345); + assert.equal(out._schema_id, 'token-usage'); + // EXCLUDED (CWE-212 boundary) + assert.equal('session_id' in out, false, 'session_id MUST be stripped (CWE-212)'); + assert.equal('transcript_path' in out, false, 'transcript_path MUST be stripped (CWE-212)'); + assert.equal('cwd' in out, false, 'cwd MUST be stripped (CWE-212)'); }); test('field-allowlist: null/undefined record handled safely', () => {