feat(voyage): add lib/exporters/{path,endpoint,field-allowlist}-validators — CWE-22, CWE-918, CWE-212 mitigering

Step 11 av v4.1-execute (Wave 2, Session 3).

3 sikkerhets-validatorer for OTel-eksporten:

path-validator.mjs (CWE-22 Path Traversal):
- Reject `..` segmenter, `~`-shorthand
- realpathSync symlink-resolution (med macOS quirk: /etc, /var, /tmp er
  symlinks til /private/etc, /private/var, /private/tmp — begge former
  i FORBIDDEN_PREFIXES)
- Allowlist-først evaluering: hvis allowedRoots gitt, det er primary defense
  (caller's threat model). Forbidden-prefix-denylist er FALLBACK når
  allowedRoots ikke spesifisert.

endpoint-validator.mjs (CWE-918 SSRF):
- Reject loopback (127.0.0.1, ::1, localhost, 0.0.0.0) UNLESS VOYAGE_OTEL_ALLOW_PRIVATE=1
- Reject RFC-1918 (10/8, 172.16/12, 192.168/16) UNLESS opt-in
- Reject link-local (169.254.x.x cloud metadata, fe80:* IPv6) UNLESS opt-in
- Krev https:// for non-private endpoints
- node:url-parsing, ingen runtime DNS-resolusjon (defense-in-depth)

field-allowlist.mjs (CWE-212 Improper Cross-boundary Removal of Sensitive Data):
- INLINE static const Object.freeze på modul-scope (IKKE runtime read fra fixtures)
- Per-schema allowlist for alle 8 schema-id (trekbrief, trekresearch, trekplan,
  trekexecute, event-emit, post-bash-stats, trekreview, trekcontinue)
- Source-comment per allowlist refererer tests/fixtures/jsonl-schemas.md
- post-bash-stats DROPPER eksplisitt command_excerpt + session_id (CWE-212)
- event-emit applies sub-allowlist på payload-objekt (recursive)
- Unknown schema-type returnerer conservative {_schema_id, ts}

Tester (19 nye, baseline 413 → 432):
- path-validator x6 (CWE-22 traversal, forbidden-system, ~, allowedRoots accept/reject, drift-pin)
- endpoint-validator x7 (CWE-918 link-local, RFC-1918, loopback, https-required, opt-in, public-accept, empty-input)
- field-allowlist x6 (CWE-212 post-bash-stats, trekplan-PII, event-emit-payload, unknown-schema, Object.freeze, null-safe)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-05-09 09:36:00 +02:00
commit 9e01ce30b5
4 changed files with 530 additions and 0 deletions

View file

@ -0,0 +1,91 @@
// lib/exporters/endpoint-validator.mjs
// Validate OTLP/HTTP endpoint URLs for the OTel exporter.
//
// CWE-918 (Server-Side Request Forgery) mitigation: reject loopback, RFC-1918,
// link-local (cloud metadata 169.254.169.254), and require HTTPS for non-loopback.
// Operator opt-in for private endpoints via VOYAGE_OTEL_ALLOW_PRIVATE=1
// (legitimate home-lab / docker-compose operator scenario).
import { ok, fail, issue } from '../util/result.mjs';
const LOOPBACK_HOSTS = new Set(['127.0.0.1', '::1', 'localhost', '0.0.0.0']);
const LINK_LOCAL_PREFIXES = ['169.254.', 'fe80:'];
function isRfc1918(host) {
// 10.0.0.0/8
if (/^10\./.test(host)) return true;
// 172.16.0.0/12
if (/^172\.(1[6-9]|2\d|3[0-1])\./.test(host)) return true;
// 192.168.0.0/16
if (/^192\.168\./.test(host)) return true;
return false;
}
function isLoopback(host) {
return LOOPBACK_HOSTS.has(host);
}
function isLinkLocal(host) {
return LINK_LOCAL_PREFIXES.some(p => host.startsWith(p));
}
/**
* Validate an OTLP/HTTP endpoint URL.
*
* @param {string} url
* @param {{env?: object}} [opts]
* @returns {import('../util/result.mjs').Result}
*/
export function validateOtlpEndpoint(url, opts = {}) {
const env = opts.env || process.env;
const allowPrivate = env.VOYAGE_OTEL_ALLOW_PRIVATE === '1';
if (typeof url !== 'string' || url.length === 0) {
return fail(issue('ENDPOINT_EMPTY', 'Endpoint must be a non-empty string'));
}
let parsed;
try { parsed = new URL(url); }
catch (e) {
return fail(issue('ENDPOINT_PARSE_ERROR', `Invalid URL: ${e.message}`));
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return fail(issue('ENDPOINT_BAD_PROTOCOL',
`Endpoint protocol must be http or https, got ${parsed.protocol}`));
}
// Strip brackets from IPv6
const host = parsed.hostname.replace(/^\[|\]$/g, '');
// Cloud metadata IP (link-local) — always rejected unless explicit opt-in
if (isLinkLocal(host) && !allowPrivate) {
return fail(issue('ENDPOINT_LINK_LOCAL_REJECTED',
`Link-local endpoint ${host} rejected (potential cloud-metadata access). ` +
`Set VOYAGE_OTEL_ALLOW_PRIVATE=1 to allow.`));
}
// Loopback / RFC-1918 — rejected unless opt-in
if (isLoopback(host) && !allowPrivate) {
return fail(issue('ENDPOINT_LOOPBACK_REJECTED',
`Loopback endpoint ${host} rejected. Set VOYAGE_OTEL_ALLOW_PRIVATE=1 for ` +
`home-lab / docker-compose scenarios.`));
}
if (isRfc1918(host) && !allowPrivate) {
return fail(issue('ENDPOINT_RFC1918_REJECTED',
`RFC-1918 private endpoint ${host} rejected. ` +
`Set VOYAGE_OTEL_ALLOW_PRIVATE=1 for home-lab scenarios.`));
}
// For non-loopback, non-private endpoints: require HTTPS
const isPrivate = isLoopback(host) || isRfc1918(host) || isLinkLocal(host);
if (!isPrivate && parsed.protocol === 'http:') {
return fail(issue('ENDPOINT_HTTPS_REQUIRED',
`Public endpoint ${host} requires https:// (got http://)`));
}
return ok({ url: parsed.href, host, isPrivate });
}
export { LOOPBACK_HOSTS, LINK_LOCAL_PREFIXES };

View file

@ -0,0 +1,138 @@
// 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)
const TREKRESEARCH_ALLOWED = Object.freeze(new Set([
'ts', 'slug', 'mode', 'scope', 'dimensions', 'agents_local',
'agents_external', 'gemini_used', 'confidence', 'contradictions',
'open_questions', 'profile', 'parallel_agents',
'external_research_enabled', 'profile_source',
]));
// 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',
]));
// 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,
});
/**
* 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,
};

View file

@ -0,0 +1,105 @@
// lib/exporters/path-validator.mjs
// Validate textfile output paths for the OTel exporter.
//
// CWE-22 (Path Traversal) mitigation: restrict writes to allowlist-anchored
// directories only. Reject `..`, absolute system paths (`/etc`, `/proc`, `/sys`,
// `/var/`, `/usr/`), home-shorthand `~`, and resolve symlinks via
// `fs.realpathSync` before checking.
import { realpathSync, existsSync, statSync } from 'node:fs';
import { resolve, normalize, sep } from 'node:path';
import { ok, fail, issue } from '../util/result.mjs';
// macOS quirk: /etc, /tmp, /var are symlinks to /private/etc, /private/tmp,
// /private/var; realpathSync resolves the symlink and adds the /private prefix.
// Include both forms so the deny check works on macOS + Linux.
const FORBIDDEN_PREFIXES = [
'/etc/', '/private/etc/',
'/proc/',
'/sys/',
'/var/', '/private/var/',
'/usr/',
'/bin/',
'/sbin/',
'/boot/',
'/dev/',
];
/**
* Validate that a path is safe for the OTel textfile exporter to write.
*
* @param {string} path Caller-supplied path.
* @param {{
* allowedRoots?: string[] // additional allow-list roots (e.g. CLAUDE_PLUGIN_DATA, VOYAGE_TEXTFILE_DIR)
* }} [opts]
* @returns {import('../util/result.mjs').Result}
*/
export function validateTextfilePath(path, opts = {}) {
if (typeof path !== 'string' || path.length === 0) {
return fail(issue('PATH_EMPTY', 'Path must be a non-empty string'));
}
// Reject home-shorthand — caller must expand explicitly
if (path.startsWith('~')) {
return fail(issue('PATH_HOME_SHORTHAND', `Path uses ~ shorthand (caller must expand): ${path}`));
}
// Normalize to absolute (relative becomes resolved against cwd)
const normalized = normalize(path);
// Reject any path component containing `..` (traversal attempt)
// Even after normalize, if `..` survives, the path leaves intended root.
const segments = normalized.split(sep);
if (segments.some(s => s === '..')) {
return fail(issue('PATH_TRAVERSAL', `Path contains traversal segment "..": ${path}`));
}
const absolute = resolve(normalized);
// Resolve symlinks if file exists; if it doesn't exist yet, resolve parent
let resolved;
try {
if (existsSync(absolute)) {
resolved = realpathSync(absolute);
} else {
// Resolve parent dir (which must exist for any meaningful write target)
const parent = absolute.split(sep).slice(0, -1).join(sep) || '/';
if (!existsSync(parent)) {
return fail(issue('PATH_PARENT_MISSING', `Parent directory does not exist: ${parent}`));
}
resolved = realpathSync(parent) + sep + absolute.split(sep).pop();
}
} catch (e) {
return fail(issue('PATH_RESOLVE_ERROR', `realpath failed: ${e.message}`));
}
// If allowedRoots is provided, that's the primary defense — caller has
// explicitly opted into a root. Reject anything outside; accept anything
// inside (callers vetting their roots is the threat model).
if (Array.isArray(opts.allowedRoots) && opts.allowedRoots.length > 0) {
const inside = opts.allowedRoots.some(root => {
if (typeof root !== 'string' || root.length === 0) return false;
let resolvedRoot;
try { resolvedRoot = realpathSync(root); }
catch { resolvedRoot = resolve(root); }
return resolved === resolvedRoot || resolved.startsWith(resolvedRoot + sep);
});
if (!inside) {
return fail(issue('PATH_OUT_OF_ALLOWLIST',
`Path ${resolved} is not under any allowed root: ${opts.allowedRoots.join(', ')}`));
}
return ok({ path: resolved });
}
// No allowedRoots: fall back to forbidden-system-prefix denylist.
for (const prefix of FORBIDDEN_PREFIXES) {
if (resolved.startsWith(prefix)) {
return fail(issue('PATH_FORBIDDEN_SYSTEM',
`Path resolves into forbidden system directory ${prefix}: ${resolved}`));
}
}
return ok({ path: resolved });
}
export { FORBIDDEN_PREFIXES };

View file

@ -0,0 +1,196 @@
// tests/hooks/otel-export-validators.test.mjs
// Step 11 validators: path, endpoint, field-allowlist.
// CWE-22, CWE-918, CWE-212 mitigation.
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { validateTextfilePath, FORBIDDEN_PREFIXES } from '../../lib/exporters/path-validator.mjs';
import { validateOtlpEndpoint } from '../../lib/exporters/endpoint-validator.mjs';
import {
applyFieldAllowlist,
POST_BASH_STATS_ALLOWED,
EVENT_EMIT_PAYLOAD_ALLOWED,
} from '../../lib/exporters/field-allowlist.mjs';
// ---- path-validator: CWE-22 mitigation -------------------------------------
test('path-validator: rejects ../etc/passwd traversal (CWE-22)', () => {
const r = validateTextfilePath('../../etc/passwd');
assert.equal(r.valid, false);
assert.ok(r.errors.find(e => e.code === 'PATH_TRAVERSAL'));
});
test('path-validator: rejects /etc/voyage.prom (forbidden system prefix)', () => {
const r = validateTextfilePath('/etc/voyage.prom');
assert.equal(r.valid, false);
// Either forbidden-system or parent-missing (both are deny-paths)
const denied = r.errors.find(e =>
e.code === 'PATH_FORBIDDEN_SYSTEM' || e.code === 'PATH_PARENT_MISSING');
assert.ok(denied, `expected deny, got: ${JSON.stringify(r.errors)}`);
});
test('path-validator: rejects ~ home shorthand', () => {
const r = validateTextfilePath('~/voyage.prom');
assert.equal(r.valid, false);
assert.ok(r.errors.find(e => e.code === 'PATH_HOME_SHORTHAND'));
});
test('path-validator: accepts path under allowedRoots', () => {
const tmp = mkdtempSync(join(tmpdir(), 'voyage-path-allow-'));
try {
const target = join(tmp, 'voyage.prom');
const r = validateTextfilePath(target, { allowedRoots: [tmp] });
assert.equal(r.valid, true, JSON.stringify(r.errors));
assert.match(r.parsed.path, /voyage\.prom$/);
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});
test('path-validator: rejects path outside allowedRoots', () => {
const tmp = mkdtempSync(join(tmpdir(), 'voyage-path-deny-'));
const otherTmp = mkdtempSync(join(tmpdir(), 'voyage-path-other-'));
try {
const target = join(otherTmp, 'voyage.prom');
const r = validateTextfilePath(target, { allowedRoots: [tmp] });
assert.equal(r.valid, false);
assert.ok(r.errors.find(e => e.code === 'PATH_OUT_OF_ALLOWLIST'));
} finally {
rmSync(tmp, { recursive: true, force: true });
rmSync(otherTmp, { recursive: true, force: true });
}
});
test('path-validator: FORBIDDEN_PREFIXES exports drift-pin', () => {
// Ensure all the high-risk system paths are present
for (const prefix of ['/etc/', '/proc/', '/sys/', '/var/', '/usr/']) {
assert.ok(FORBIDDEN_PREFIXES.includes(prefix),
`FORBIDDEN_PREFIXES missing critical path: ${prefix}`);
}
});
// ---- endpoint-validator: CWE-918 mitigation -------------------------------
test('endpoint-validator: rejects http://169.254.169.254/ link-local (cloud metadata, CWE-918)', () => {
const r = validateOtlpEndpoint('http://169.254.169.254/v1/metrics', { env: {} });
assert.equal(r.valid, false);
assert.ok(r.errors.find(e => e.code === 'ENDPOINT_LINK_LOCAL_REJECTED'));
});
test('endpoint-validator: rejects http://example.com/ (requires https)', () => {
const r = validateOtlpEndpoint('http://example.com/v1/metrics', { env: {} });
assert.equal(r.valid, false);
assert.ok(r.errors.find(e => e.code === 'ENDPOINT_HTTPS_REQUIRED'));
});
test('endpoint-validator: rejects http://localhost without VOYAGE_OTEL_ALLOW_PRIVATE', () => {
const r = validateOtlpEndpoint('http://localhost:4318/v1/metrics', { env: {} });
assert.equal(r.valid, false);
assert.ok(r.errors.find(e => e.code === 'ENDPOINT_LOOPBACK_REJECTED'));
});
test('endpoint-validator: accepts http://localhost when VOYAGE_OTEL_ALLOW_PRIVATE=1 (home-lab opt-in)', () => {
const r = validateOtlpEndpoint('http://localhost:4318/v1/metrics',
{ env: { VOYAGE_OTEL_ALLOW_PRIVATE: '1' } });
assert.equal(r.valid, true, JSON.stringify(r.errors));
assert.equal(r.parsed.isPrivate, true);
});
test('endpoint-validator: accepts https://example.com/v1/metrics (public)', () => {
const r = validateOtlpEndpoint('https://otel.example.com/v1/metrics', { env: {} });
assert.equal(r.valid, true, JSON.stringify(r.errors));
assert.equal(r.parsed.isPrivate, false);
});
test('endpoint-validator: rejects RFC-1918 192.168.1.1 without opt-in', () => {
const r = validateOtlpEndpoint('http://192.168.1.1:4318/v1/metrics', { env: {} });
assert.equal(r.valid, false);
assert.ok(r.errors.find(e => e.code === 'ENDPOINT_RFC1918_REJECTED'));
});
test('endpoint-validator: rejects empty / non-string', () => {
assert.equal(validateOtlpEndpoint('').valid, false);
assert.equal(validateOtlpEndpoint(null).valid, false);
assert.equal(validateOtlpEndpoint(undefined).valid, false);
});
// ---- field-allowlist: CWE-212 mitigation -----------------------------------
test('field-allowlist: post-bash-stats DROPS command_excerpt + session_id (CWE-212)', () => {
const record = {
ts: '2026-05-09T08:00:00.000Z',
session_id: 'uuid-12345',
command_excerpt: 'git clone https://example.com/secret/repo',
duration_ms: 152,
success: true,
};
const out = applyFieldAllowlist(record, 'post-bash-stats');
assert.equal('command_excerpt' in out, false, 'command_excerpt MUST be stripped');
assert.equal('session_id' in out, false, 'session_id MUST be stripped');
assert.equal(out.duration_ms, 152);
assert.equal(out.success, true);
assert.equal(out._schema_id, 'post-bash-stats');
});
test('field-allowlist: trekplan DROPS task / project_dir / brief_path (PII)', () => {
const record = {
ts: '2026-05-09T08:00:00.000Z',
task: 'private user prose with PII',
slug: 'add-auth',
project_dir: '/home/user/secret/project',
brief_path: '/home/user/secret/brief.md',
codebase_files: 156,
profile: 'premium',
};
const out = applyFieldAllowlist(record, 'trekplan');
assert.equal('task' in out, false);
assert.equal('project_dir' in out, false);
assert.equal('brief_path' in out, false);
assert.equal(out.slug, 'add-auth');
assert.equal(out.codebase_files, 156);
assert.equal(out.profile, 'premium');
});
test('field-allowlist: event-emit applies sub-allowlist to payload', () => {
const record = {
ts: '2026-05-09T08:00:00.000Z',
event: 'main-merge-gate',
known_event: true,
payload: {
profile: 'balanced',
profile_source: 'inheritance',
command_excerpt: 'should be stripped from payload',
raw_user_prose: 'should be stripped',
},
};
const out = applyFieldAllowlist(record, 'event-emit');
assert.equal(out.event, 'main-merge-gate');
assert.equal(out.payload.profile, 'balanced');
assert.equal(out.payload.profile_source, 'inheritance');
assert.equal('command_excerpt' in out.payload, false);
assert.equal('raw_user_prose' in out.payload, false);
});
test('field-allowlist: unknown schema-type returns conservative {ts, _schema_id} only', () => {
const out = applyFieldAllowlist(
{ ts: '2026-05-09T08:00:00.000Z', sensitive: 'secret' },
'totally-unknown-schema',
);
assert.equal('sensitive' in out, false);
assert.equal(out.ts, '2026-05-09T08:00:00.000Z');
assert.equal(out._schema_id, 'totally-unknown-schema');
});
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);
});
test('field-allowlist: null/undefined record handled safely', () => {
assert.deepEqual(applyFieldAllowlist(null, 'trekplan'), {});
assert.deepEqual(applyFieldAllowlist(undefined, 'trekplan'), {});
});