// lib/exporters/field-allowlist.mjs // CWE-212 (Improper Cross-boundary Removal of Sensitive Data) mitigation: // inline static allowlist that strips PII / high-cardinality fields before // records reach the OTel exporter. // // The allowlist is INLINE STATIC at module-scope (Object.freeze). NEVER read // runtime from tests/fixtures/ — fixture file is AUTHORING reference only // (Source: tests/fixtures/jsonl-schemas.md per Step 1 audit). // // Per-schema allowlists drop: // - command_excerpt (post-bash-stats: arbitrary Bash slice, CWE-212) // - session_id (UUID, high-cardinality, log-only) // - task / question / project_dir / brief_path / plan / project (PII-ish prose, paths) // - payload (event-emit: open-ended object, allowlist payload-keys instead) // - phase_models (structured object, summarize via `profile` label instead) // - counts (review nested object, flatten to per-severity metrics elsewhere) // ---- Per-schema allowlists (Source: tests/fixtures/jsonl-schemas.md) ------- // Source: tests/fixtures/jsonl-schemas.md row 1 (trekbrief) const TREKBRIEF_ALLOWED = Object.freeze(new Set([ 'ts', 'slug', 'mode', 'interview_turns', 'review_iterations', 'brief_quality', 'research_topics', 'auto_research', 'auto_result', 'profile', 'profile_source', ])); // Source: tests/fixtures/jsonl-schemas.md row 2 (trekresearch) // `engine` is a low-cardinality label (swarm|deep-research) emitted at // commands/trekresearch.md:533 and promised in prose (:570-572). // DENY BY OMISSION: question (free prose), project_dir + brief_path // (filesystem paths) are written into the jsonl but MUST NOT reach the // exporter. // The five v5.10 measurement fields are allowlisted too: `effort` is a // low-cardinality label (low|standard|high) and the grouping key the // measurement gate is computed on; `unique_sources`, `dimensions_baseline`, // `conv_turns` and `empty_turns` are plain counters. None of them carry prose // or paths. `dimensions_baseline_preserved` is a boolean, and it exists BECAUSE // prose is denied here: the activation check needs to know that the final // dimension list is a superset of the interview-derived one, and the dimension // NAMES that would show it directly are free prose that must not reach the // exporter. A boolean attestation carries the fact without the payload. const TREKRESEARCH_ALLOWED = Object.freeze(new Set([ 'ts', 'slug', 'mode', 'scope', 'engine', 'dimensions', 'agents_local', 'agents_external', 'gemini_used', 'confidence', 'contradictions', 'open_questions', 'profile', 'parallel_agents', 'external_research_enabled', 'profile_source', 'effort', 'unique_sources', 'dimensions_baseline', 'conv_turns', 'empty_turns', 'dimensions_baseline_preserved', ])); // Source: tests/fixtures/jsonl-schemas.md row 3 (trekplan) const TREKPLAN_ALLOWED = Object.freeze(new Set([ 'ts', 'slug', 'mode', 'codebase_size', 'codebase_files', 'agents_deployed', 'deep_dives', 'research_briefs_used', 'research_scout_used', 'critic_verdict', 'guardian_verdict', 'outcome', 'profile', 'parallel_agents', 'profile_source', ])); // Source: tests/fixtures/jsonl-schemas.md row 4 (trekexecute Phase 9 record) const TREKEXECUTE_ALLOWED = Object.freeze(new Set([ 'ts', 'plan_type', 'mode', 'result', 'steps_total', 'steps_passed', 'steps_failed', 'steps_skipped', 'failed_at_step', 'profile', 'profile_source', ])); // Source: tests/fixtures/jsonl-schemas.md row 5 (trekexecute autonomy events) const EVENT_EMIT_ALLOWED = Object.freeze(new Set([ 'ts', 'event', 'known_event', ])); // Source: tests/fixtures/jsonl-schemas.md row 5 (event-emit payload sub-allowlist) const EVENT_EMIT_PAYLOAD_ALLOWED = Object.freeze(new Set([ 'profile', 'phase_models', 'parallel_agents', 'external_research_enabled', 'profile_source', 'brief_quality', 'plan_grade', ])); // Source: tests/fixtures/jsonl-schemas.md row 6 (post-bash-stats — PII-flag) // CWE-212: command_excerpt + session_id MUST be stripped. const POST_BASH_STATS_ALLOWED = Object.freeze(new Set([ 'ts', 'duration_ms', 'success', ])); // Source: tests/fixtures/jsonl-schemas.md row 7 (trekreview) const TREKREVIEW_ALLOWED = Object.freeze(new Set([ 'ts', 'slug', 'verdict', 'reviewed_files_count', 'mode', 'duration_ms', 'profile', 'profile_source', ])); // Source: tests/fixtures/jsonl-schemas.md row 8 (trekcontinue) 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, 'trekresearch': TREKRESEARCH_ALLOWED, 'trekplan': TREKPLAN_ALLOWED, 'trekexecute': TREKEXECUTE_ALLOWED, 'event-emit': EVENT_EMIT_ALLOWED, 'post-bash-stats': POST_BASH_STATS_ALLOWED, 'post_bash_stats': POST_BASH_STATS_ALLOWED, // common alt-spelling 'trekreview': TREKREVIEW_ALLOWED, 'trekcontinue': TREKCONTINUE_ALLOWED, 'token-usage': TOKEN_USAGE_ALLOWED, }); /** * Apply field-allowlist to a record. Drops any field not in the schema's * allowlist. Returns a new object (does not mutate input). * * Always preserves `_schema_id` (caller-supplied identifier used by exporters * to group records by source). * * @param {object} record * @param {string} schemaType Schema-id (e.g. 'trekplan', 'post-bash-stats') * @returns {object} Redacted record with only allowlisted fields. */ export function applyFieldAllowlist(record, schemaType) { if (!record || typeof record !== 'object') return {}; const allowed = SCHEMA_ALLOWLISTS[schemaType]; if (!allowed) { // Unknown schema-type: be conservative — keep ts only + _schema_id. return record.ts ? { _schema_id: schemaType, ts: record.ts } : { _schema_id: schemaType }; } const out = { _schema_id: schemaType }; for (const k of allowed) { if (k in record) out[k] = record[k]; } // Special: event-emit records have a `payload` sub-object that needs its // own allowlist applied recursively. if (schemaType === 'event-emit' && record.payload && typeof record.payload === 'object') { const subOut = {}; for (const k of EVENT_EMIT_PAYLOAD_ALLOWED) { if (k in record.payload) subOut[k] = record.payload[k]; } out.payload = subOut; } return out; } export { SCHEMA_ALLOWLISTS, EVENT_EMIT_PAYLOAD_ALLOWED, POST_BASH_STATS_ALLOWED, TREKBRIEF_ALLOWED, TREKRESEARCH_ALLOWED, TREKPLAN_ALLOWED, TREKEXECUTE_ALLOWED, TREKREVIEW_ALLOWED, TREKCONTINUE_ALLOWED, TOKEN_USAGE_ALLOWED, };