chore(cleanup): R-b R1 — Voyage's own stats, events and OTel export removed
Removes Voyage's measurement, statistics and export apparatus (decision 189; condition 175 met in step 1) and the R-a leftovers: lib/stats/, lib/exporters/, the otel-export and post-bash-stats hooks, yardstick, end-state and comparison gates, synthesis-measure, gold-scorer, docs/observability.md, examples/observability/, docs/T1-cc26-delegated-orchestration.md and their tests and fixtures. The six command stats phases and the /trekexecute main-merge event blocks go; intent-approval --stamp no longer emits brief-approved. README and docs point to Claude Code's own OpenTelemetry. Files removed: 52. Files edited: 37. npm test: 2930 tests (2928 pass / 0 fail / 2 skipped) before, 2600 tests (2598 pass / 0 fail / 2 skipped) after. removed-surfaces group R1 green. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
92010d20de
commit
21480e8627
89 changed files with 94 additions and 10290 deletions
|
|
@ -527,7 +527,7 @@ test('write snippet then real hook: the loop’s own writer arms the enforcement
|
|||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Wiring — pattern from tests/hooks/hooks-json-stop-wired.test.mjs
|
||||
// Wiring
|
||||
// -----------------------------------------------------------------------
|
||||
function invocationOf(h) {
|
||||
return [h.command || '', ...(h.args || [])].join(' ').trim();
|
||||
|
|
|
|||
|
|
@ -1,73 +0,0 @@
|
|||
// SC-13: hooks.json wires Stop event to otel-export.mjs
|
||||
// HIGH-risk-mitigering — verify deterministic config-pinning (mønster fra
|
||||
// tests/lib/doc-consistency.test.mjs).
|
||||
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const HOOKS_JSON_PATH = resolve(__dirname, '../../hooks/hooks.json');
|
||||
|
||||
function loadHooksJson() {
|
||||
const raw = readFileSync(HOOKS_JSON_PATH, 'utf8');
|
||||
return JSON.parse(raw);
|
||||
}
|
||||
|
||||
// Hooks may use shell form ({command: "node X.mjs"}) or exec form
|
||||
// ({command: "node", args: ["X.mjs"]}, CC 2.1.139). Normalize both to a single
|
||||
// invocation string so assertions are form-agnostic.
|
||||
function invocationOf(h) {
|
||||
return [h.command || '', ...(h.args || [])].join(' ').trim();
|
||||
}
|
||||
|
||||
test('hooks.json — Stop key exists with at least one entry', () => {
|
||||
const cfg = loadHooksJson();
|
||||
assert.ok(cfg.hooks, 'hooks.json mangler top-level "hooks" object');
|
||||
assert.ok(Array.isArray(cfg.hooks.Stop), 'hooks.json mangler "Stop" array');
|
||||
assert.ok(cfg.hooks.Stop.length >= 1, 'Stop array er tom — forventet ≥1 entry');
|
||||
});
|
||||
|
||||
test('hooks.json — Stop entry refererer otel-export.mjs', () => {
|
||||
const cfg = loadHooksJson();
|
||||
const stopEntries = cfg.hooks.Stop;
|
||||
const allInvocations = stopEntries.flatMap((entry) =>
|
||||
(entry.hooks || []).map(invocationOf),
|
||||
);
|
||||
const hasOtelExport = allInvocations.some((cmd) => cmd.includes('otel-export.mjs'));
|
||||
assert.ok(
|
||||
hasOtelExport,
|
||||
`ingen Stop-hook refererer otel-export.mjs. Funnet: ${JSON.stringify(allInvocations)}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('hooks.json — Stop entry bruker ${CLAUDE_PLUGIN_ROOT}-substitusjon', () => {
|
||||
const cfg = loadHooksJson();
|
||||
const stopEntries = cfg.hooks.Stop;
|
||||
const otelEntry = stopEntries
|
||||
.flatMap((entry) => entry.hooks || [])
|
||||
.find((h) => invocationOf(h).includes('otel-export.mjs'));
|
||||
assert.ok(otelEntry, 'fant ikke otel-export-entry i Stop');
|
||||
const otelInvocation = invocationOf(otelEntry);
|
||||
assert.match(
|
||||
otelInvocation,
|
||||
/\$\{CLAUDE_PLUGIN_ROOT\}/,
|
||||
'otel-export-invocation bruker ikke ${CLAUDE_PLUGIN_ROOT}-prefix — relative paths feiler i headless',
|
||||
);
|
||||
assert.match(
|
||||
otelInvocation,
|
||||
/^node\s+/,
|
||||
'otel-export-invocation starter ikke med "node " — invocation-form ikke korrekt',
|
||||
);
|
||||
});
|
||||
|
||||
test('hooks.json — Stop entry har "type": "command"', () => {
|
||||
const cfg = loadHooksJson();
|
||||
const stopEntries = cfg.hooks.Stop;
|
||||
const otelHook = stopEntries
|
||||
.flatMap((entry) => entry.hooks || [])
|
||||
.find((h) => invocationOf(h).includes('otel-export.mjs'));
|
||||
assert.equal(otelHook.type, 'command', 'otel-export-hook mangler "type": "command"');
|
||||
});
|
||||
|
|
@ -1,110 +0,0 @@
|
|||
// tests/hooks/otel-export-otlp.test.mjs
|
||||
// SC #13: lib/exporters/otlp-format.mjs returns OTLP/JSON v1.0 metrics payload
|
||||
// with INTEGER (not string) enum constants and timeUnixNano as decimal STRING
|
||||
// (JS precision-loss mitigation per research/01 + risk-assessor CRITICAL 2).
|
||||
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
transformToOtlpJson,
|
||||
AGG_TEMPORALITY_CUMULATIVE,
|
||||
AGG_TEMPORALITY_DELTA,
|
||||
AGG_TEMPORALITY_UNSPECIFIED,
|
||||
} from '../../lib/exporters/otlp-format.mjs';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const FIXTURES = join(__dirname, '..', 'fixtures');
|
||||
|
||||
function loadJsonl(name) {
|
||||
const text = readFileSync(join(FIXTURES, name), 'utf-8');
|
||||
return text.trim().split('\n').filter(Boolean).map(l => JSON.parse(l));
|
||||
}
|
||||
|
||||
test('SC #13: aggregationTemporality is INTEGER (typeof === "number"), not string', () => {
|
||||
const records = loadJsonl('stats-sample.jsonl');
|
||||
const payload = transformToOtlpJson(records);
|
||||
// Find a sum metric (steps_passed is a counter)
|
||||
const metrics = payload.resourceMetrics[0].scopeMetrics[0].metrics;
|
||||
const sumMetric = metrics.find(m => 'sum' in m);
|
||||
assert.ok(sumMetric, `expected at least one sum-metric in payload, got ${metrics.length} metrics`);
|
||||
// CRITICAL: this assertion is the heart of SC #13 — typeof MUST be 'number'
|
||||
assert.equal(typeof sumMetric.sum.aggregationTemporality, 'number',
|
||||
`aggregationTemporality must be INTEGER (typeof number), got ${typeof sumMetric.sum.aggregationTemporality}`);
|
||||
assert.equal(sumMetric.sum.aggregationTemporality, AGG_TEMPORALITY_CUMULATIVE);
|
||||
assert.equal(sumMetric.sum.aggregationTemporality, 2);
|
||||
});
|
||||
|
||||
test('SC #13: enum constants exported as integer literals (drift-pin)', () => {
|
||||
assert.equal(typeof AGG_TEMPORALITY_UNSPECIFIED, 'number');
|
||||
assert.equal(AGG_TEMPORALITY_UNSPECIFIED, 0);
|
||||
assert.equal(typeof AGG_TEMPORALITY_DELTA, 'number');
|
||||
assert.equal(AGG_TEMPORALITY_DELTA, 1);
|
||||
assert.equal(typeof AGG_TEMPORALITY_CUMULATIVE, 'number');
|
||||
assert.equal(AGG_TEMPORALITY_CUMULATIVE, 2);
|
||||
});
|
||||
|
||||
test('SC #13: timeUnixNano is decimal STRING (typeof === "string"), JS precision-loss mitigation', () => {
|
||||
const records = loadJsonl('stats-sample.jsonl');
|
||||
const payload = transformToOtlpJson(records);
|
||||
const metrics = payload.resourceMetrics[0].scopeMetrics[0].metrics;
|
||||
// Pick first metric with a data point
|
||||
const m = metrics.find(x => (x.sum?.dataPoints?.length || x.gauge?.dataPoints?.length) > 0);
|
||||
const dp = (m.sum || m.gauge).dataPoints[0];
|
||||
assert.equal(typeof dp.timeUnixNano, 'string',
|
||||
`timeUnixNano must be decimal STRING, got ${typeof dp.timeUnixNano}: ${dp.timeUnixNano}`);
|
||||
assert.equal(typeof dp.startTimeUnixNano, 'string');
|
||||
// Should be a valid decimal-digit string
|
||||
assert.match(dp.timeUnixNano, /^\d+$/);
|
||||
});
|
||||
|
||||
test('SC #13: structural shape — resourceMetrics[].scopeMetrics[].metrics[] hierarchy', () => {
|
||||
const records = loadJsonl('stats-sample.jsonl');
|
||||
const payload = transformToOtlpJson(records);
|
||||
assert.ok(Array.isArray(payload.resourceMetrics));
|
||||
assert.ok(payload.resourceMetrics.length >= 1);
|
||||
assert.ok(payload.resourceMetrics[0].resource);
|
||||
assert.ok(Array.isArray(payload.resourceMetrics[0].scopeMetrics));
|
||||
assert.ok(payload.resourceMetrics[0].scopeMetrics[0].scope);
|
||||
assert.equal(payload.resourceMetrics[0].scopeMetrics[0].scope.name, 'voyage');
|
||||
assert.ok(Array.isArray(payload.resourceMetrics[0].scopeMetrics[0].metrics));
|
||||
});
|
||||
|
||||
test('Empty input: returns valid OTLP envelope with empty metrics array', () => {
|
||||
const payload = transformToOtlpJson([]);
|
||||
assert.ok(Array.isArray(payload.resourceMetrics));
|
||||
assert.equal(payload.resourceMetrics[0].scopeMetrics[0].metrics.length, 0);
|
||||
});
|
||||
|
||||
test('isSum heuristic: counter-named metrics get sum + isMonotonic; others get gauge', () => {
|
||||
const records = [
|
||||
{ _schema_id: 'test', ts: '2026-05-09T08:00:00.000Z', steps_total: 10 }, // counter
|
||||
{ _schema_id: 'test', ts: '2026-05-09T08:00:00.000Z', cpu_pct: 42.5 }, // gauge
|
||||
];
|
||||
const payload = transformToOtlpJson(records);
|
||||
const metrics = payload.resourceMetrics[0].scopeMetrics[0].metrics;
|
||||
const totalMetric = metrics.find(m => m.name.endsWith('steps_total'));
|
||||
const cpuMetric = metrics.find(m => m.name.endsWith('cpu_pct'));
|
||||
assert.ok(totalMetric.sum, 'counter should have sum');
|
||||
assert.equal(totalMetric.sum.isMonotonic, true);
|
||||
assert.equal(typeof totalMetric.sum.aggregationTemporality, 'number');
|
||||
assert.ok(cpuMetric.gauge, 'non-counter should have gauge');
|
||||
assert.ok(!cpuMetric.sum, 'gauge should not have sum');
|
||||
});
|
||||
|
||||
test('Allowlist redacted: callers strip command_excerpt before passing — verify nothing leaks', () => {
|
||||
const record = {
|
||||
_schema_id: 'post_bash_stats',
|
||||
ts: '2026-05-09T08:00:00.000Z',
|
||||
duration_ms: 152,
|
||||
success: true,
|
||||
};
|
||||
const payload = transformToOtlpJson([record]);
|
||||
const json = JSON.stringify(payload);
|
||||
assert.ok(!json.includes('command_excerpt'));
|
||||
assert.ok(!json.includes('session_id'));
|
||||
// Should contain duration_ms metric
|
||||
assert.match(json, /post_bash_stats\.duration_ms/);
|
||||
});
|
||||
|
|
@ -1,135 +0,0 @@
|
|||
// tests/hooks/otel-export-textfile.test.mjs
|
||||
// SC #12: lib/exporters/textfile-format.mjs produces deterministic Prometheus
|
||||
// text-format output that matches expected.prom byte-for-byte.
|
||||
//
|
||||
// To regenerate snapshot:
|
||||
// node scripts/gen-expected-prom.mjs > tests/fixtures/expected.prom
|
||||
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
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');
|
||||
|
||||
function loadJsonl(name) {
|
||||
const text = readFileSync(join(FIXTURES, name), 'utf-8');
|
||||
return text.trim().split('\n').filter(Boolean).map(l => JSON.parse(l));
|
||||
}
|
||||
|
||||
test('SC #12: stats-sample.jsonl → expected.prom snapshot byte-for-byte match', () => {
|
||||
const records = loadJsonl('stats-sample.jsonl');
|
||||
const actual = transformToPrometheus(records);
|
||||
const expected = readFileSync(join(FIXTURES, 'expected.prom'), 'utf-8');
|
||||
assert.equal(actual, expected,
|
||||
`Snapshot drift detected. Regenerate via:\n` +
|
||||
` node scripts/gen-expected-prom.mjs > tests/fixtures/expected.prom`);
|
||||
});
|
||||
|
||||
test('Step 9: the four numeric STORM fields are metric families and effort is a label', () => {
|
||||
const expected = readFileSync(join(FIXTURES, 'expected.prom'), 'utf-8');
|
||||
for (const field of ['unique_sources', 'dimensions_baseline', 'conv_turns', 'empty_turns']) {
|
||||
assert.match(
|
||||
expected,
|
||||
new RegExp(`^# TYPE voyage_trekresearch_${field} `, 'm'),
|
||||
`${field} must appear as its own metric family — a numeric that never becomes a metric cannot be measured`,
|
||||
);
|
||||
}
|
||||
// effort is a low-cardinality string: it must ride along as a LABEL, never
|
||||
// as a metric family (a label is what makes high-vs-standard groupable).
|
||||
assert.match(expected, /effort="[a-z]+"/, 'effort must be emitted as a label');
|
||||
assert.doesNotMatch(
|
||||
expected,
|
||||
/^# TYPE voyage_trekresearch_effort /m,
|
||||
'effort must not become a metric family',
|
||||
);
|
||||
});
|
||||
|
||||
test('empty-input handling: [] returns empty string (no headers)', () => {
|
||||
assert.equal(transformToPrometheus([]), '');
|
||||
assert.equal(transformToPrometheus(null), '');
|
||||
assert.equal(transformToPrometheus(undefined), '');
|
||||
});
|
||||
|
||||
test('allowlist-redaction: caller-redacted records (without command_excerpt/session_id) emit cleanly', () => {
|
||||
// Simulate an allowlist-applied post-bash-stats record (command_excerpt + session_id removed)
|
||||
const record = {
|
||||
_schema_id: 'post_bash_stats',
|
||||
ts: '2026-05-09T08:00:00.000Z',
|
||||
duration_ms: 152,
|
||||
success: true,
|
||||
};
|
||||
const out = transformToPrometheus([record]);
|
||||
// Must NOT contain command_excerpt nor session_id (caller's responsibility, but verify)
|
||||
assert.ok(!out.includes('command_excerpt'), 'command_excerpt leaked into output');
|
||||
assert.ok(!out.includes('session_id'), 'session_id leaked into output');
|
||||
// Must contain duration_ms metric
|
||||
assert.match(out, /voyage_post_bash_stats_duration_ms/);
|
||||
assert.match(out, / 152$/m);
|
||||
// Boolean coerced to 1
|
||||
assert.match(out, /voyage_post_bash_stats_success.* 1$/m);
|
||||
});
|
||||
|
||||
test('NO client-side timestamps in output (per research/01 node_exporter#1284 mitigation)', () => {
|
||||
const records = loadJsonl('stats-sample.jsonl');
|
||||
const out = transformToPrometheus(records);
|
||||
const lines = out.split('\n');
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('#') || line === '') continue;
|
||||
// Sample line format: metric{labels} value [timestamp]
|
||||
// We must NOT have a trailing numeric timestamp after the value.
|
||||
const parts = line.trim().split(' ');
|
||||
assert.ok(parts.length === 2,
|
||||
`Line has unexpected token count (timestamp leaked?): ${line}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('normalizeMetricName: dots/dashes/spaces → underscore, lowercase, voyage_ prefix', () => {
|
||||
assert.equal(normalizeMetricName('voyage.hook.duration_ms'), 'voyage_voyage_hook_duration_ms');
|
||||
assert.equal(normalizeMetricName('Plan-Critic Verdict'), 'voyage_plan_critic_verdict');
|
||||
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);
|
||||
const out2 = transformToPrometheus([...records]);
|
||||
assert.equal(out1, out2);
|
||||
});
|
||||
|
|
@ -1,147 +0,0 @@
|
|||
// tests/hooks/otel-export-token-capture.test.mjs
|
||||
// SKAL-2 Step 4 — opt-in main-context token/cost capture folded into the Stop
|
||||
// hook (otel-export.mjs), gated by VOYAGE_TOKEN_METER.
|
||||
//
|
||||
// Fail-open contract: any error → exit 0, no token file, Stop never blocked.
|
||||
// Pattern: tests/hooks/otel-export.test.mjs (setupDataDir + runHookWithEnv).
|
||||
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { mkdtempSync, rmSync, existsSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { runHookWithEnv } from '../helpers/hook-helper.mjs';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const HOOK_PATH = join(HERE, '..', '..', 'hooks', 'scripts', 'otel-export.mjs');
|
||||
const FIXTURE = join(HERE, '..', 'fixtures', 'token-usage', 'sample-transcript.jsonl');
|
||||
const TOKEN_FILE = 'token-usage-stats.jsonl';
|
||||
|
||||
const mkDataDir = () => mkdtempSync(join(tmpdir(), 'voyage-token-capture-'));
|
||||
const stdinFor = (extra = {}) => JSON.stringify({ transcript_path: FIXTURE, session_id: 'sess-1', ...extra });
|
||||
const readToken = (dir) => readFileSync(join(dir, TOKEN_FILE), 'utf-8').trim().split('\n').filter(Boolean).map(JSON.parse);
|
||||
|
||||
test('VOYAGE_TOKEN_METER=1 → writes token-usage-stats.jsonl with expected totals + scope:main-context', async () => {
|
||||
const dir = mkDataDir();
|
||||
try {
|
||||
const r = await runHookWithEnv(HOOK_PATH, stdinFor(), {
|
||||
VOYAGE_TOKEN_METER: '1',
|
||||
VOYAGE_EXPORT_MODE: 'off',
|
||||
CLAUDE_PLUGIN_DATA: dir,
|
||||
});
|
||||
assert.equal(r.code, 0);
|
||||
assert.equal(existsSync(join(dir, TOKEN_FILE)), true, 'token file must be written');
|
||||
const recs = readToken(dir);
|
||||
assert.equal(recs.length, 1);
|
||||
const rec = recs[0];
|
||||
// Fixture totals: dedup-by-requestId + sidechain-exclusion (verified in Step 1).
|
||||
assert.equal(rec.tokens_input, 3000);
|
||||
assert.equal(rec.tokens_output, 500);
|
||||
assert.equal(rec.tokens_cache_creation, 1000);
|
||||
assert.equal(rec.tokens_cache_read, 6000);
|
||||
assert.equal(rec.scope, 'main-context');
|
||||
assert.equal(rec.model, 'claude-opus-4-8');
|
||||
assert.ok(Math.abs(rec.cost_usd - 0.03675) < 1e-9, `cost ${rec.cost_usd}`);
|
||||
assert.equal(rec.session_id, 'sess-1');
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('UPSERT — running twice for the same session_id keeps ONE line (not two)', async () => {
|
||||
const dir = mkDataDir();
|
||||
try {
|
||||
const env = { VOYAGE_TOKEN_METER: '1', VOYAGE_EXPORT_MODE: 'off', CLAUDE_PLUGIN_DATA: dir };
|
||||
await runHookWithEnv(HOOK_PATH, stdinFor(), env);
|
||||
const r2 = await runHookWithEnv(HOOK_PATH, stdinFor(), env);
|
||||
assert.equal(r2.code, 0);
|
||||
const recs = readToken(dir);
|
||||
assert.equal(recs.length, 1, 'upsert must NOT append a second line for the same session');
|
||||
assert.equal(recs[0].tokens_input, 3000);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('UPSERT — distinct session_ids append (one line per session)', async () => {
|
||||
const dir = mkDataDir();
|
||||
try {
|
||||
const env = { VOYAGE_TOKEN_METER: '1', VOYAGE_EXPORT_MODE: 'off', CLAUDE_PLUGIN_DATA: dir };
|
||||
await runHookWithEnv(HOOK_PATH, stdinFor({ session_id: 'A' }), env);
|
||||
await runHookWithEnv(HOOK_PATH, stdinFor({ session_id: 'B' }), env);
|
||||
const recs = readToken(dir);
|
||||
assert.equal(recs.length, 2);
|
||||
assert.deepEqual(recs.map(r => r.session_id).sort(), ['A', 'B']);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('OPT-IN — VOYAGE_TOKEN_METER unset → NO token file written', async () => {
|
||||
const dir = mkDataDir();
|
||||
try {
|
||||
const r = await runHookWithEnv(HOOK_PATH, stdinFor(), {
|
||||
VOYAGE_TOKEN_METER: '', // explicit empty mimics unset
|
||||
VOYAGE_EXPORT_MODE: 'off',
|
||||
CLAUDE_PLUGIN_DATA: dir,
|
||||
});
|
||||
assert.equal(r.code, 0);
|
||||
assert.equal(existsSync(join(dir, TOKEN_FILE)), false, 'no token file without opt-in');
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('FAIL-OPEN — malformed stdin → exit 0, no token file, no throw', async () => {
|
||||
const dir = mkDataDir();
|
||||
try {
|
||||
const r = await runHookWithEnv(HOOK_PATH, 'not valid json {{{', {
|
||||
VOYAGE_TOKEN_METER: '1',
|
||||
VOYAGE_EXPORT_MODE: 'off',
|
||||
CLAUDE_PLUGIN_DATA: dir,
|
||||
});
|
||||
assert.equal(r.code, 0);
|
||||
assert.equal(existsSync(join(dir, TOKEN_FILE)), false);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('FAIL-OPEN — missing transcript file → exit 0, no token file', async () => {
|
||||
const dir = mkDataDir();
|
||||
try {
|
||||
const r = await runHookWithEnv(
|
||||
HOOK_PATH,
|
||||
JSON.stringify({ transcript_path: '/no/such/transcript.jsonl', session_id: 'x' }),
|
||||
{ VOYAGE_TOKEN_METER: '1', VOYAGE_EXPORT_MODE: 'off', CLAUDE_PLUGIN_DATA: dir },
|
||||
);
|
||||
assert.equal(r.code, 0);
|
||||
assert.equal(existsSync(join(dir, TOKEN_FILE)), false);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('capture coexists with export — VOYAGE_TOKEN_METER=1 + textfile mode writes BOTH', async () => {
|
||||
const dir = mkDataDir();
|
||||
try {
|
||||
// Seed a stats file so the export path has something to write.
|
||||
writeFileSync(join(dir, 'trekplan-stats.jsonl'),
|
||||
JSON.stringify({ ts: '2026-06-26T08:00:00.000Z', slug: 't', mode: 'default', codebase_files: 10, profile: 'premium' }) + '\n');
|
||||
const r = await runHookWithEnv(HOOK_PATH, stdinFor(), {
|
||||
VOYAGE_TOKEN_METER: '1',
|
||||
VOYAGE_EXPORT_MODE: 'textfile',
|
||||
CLAUDE_PLUGIN_DATA: dir,
|
||||
});
|
||||
assert.equal(r.code, 0);
|
||||
assert.equal(existsSync(join(dir, TOKEN_FILE)), true, 'token file written');
|
||||
assert.equal(existsSync(join(dir, 'voyage.prom')), true, 'export still completed');
|
||||
// The exported textfile should now also carry the token metric (STATS_FILES wiring).
|
||||
const prom = readFileSync(join(dir, 'voyage.prom'), 'utf-8');
|
||||
assert.match(prom, /voyage_token_usage_tokens_input/);
|
||||
assert.ok(!prom.includes('sess-1'), 'session_id must not leak into export (CWE-212)');
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
|
@ -1,349 +0,0 @@
|
|||
// 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,
|
||||
TOKEN_USAGE_ALLOWED,
|
||||
TREKRESEARCH_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/ — PERMANENTLY blocked (CWE-918 cloud metadata)', () => {
|
||||
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_HARD_BLOCKED'));
|
||||
});
|
||||
|
||||
test('endpoint-validator: 169.254.169.254 stays blocked EVEN WITH VOYAGE_OTEL_ALLOW_PRIVATE=1', () => {
|
||||
// Cloud metadata service is qualitatively different from RFC-1918 home-lab
|
||||
// access — operator-trust is NOT extended here. AWS/GCP/Azure metadata
|
||||
// exposes IAM credentials and can compromise the entire cloud account.
|
||||
const r = validateOtlpEndpoint('http://169.254.169.254/v1/metrics',
|
||||
{ env: { VOYAGE_OTEL_ALLOW_PRIVATE: '1' } });
|
||||
assert.equal(r.valid, false, 'cloud metadata MUST stay blocked even with opt-in');
|
||||
assert.ok(r.errors.find(e => e.code === 'ENDPOINT_HARD_BLOCKED'));
|
||||
});
|
||||
|
||||
test('endpoint-validator: AliCloud metadata 100.100.100.200 PERMANENTLY blocked', () => {
|
||||
const r = validateOtlpEndpoint('http://100.100.100.200/latest/meta-data',
|
||||
{ env: { VOYAGE_OTEL_ALLOW_PRIVATE: '1' } });
|
||||
assert.equal(r.valid, false);
|
||||
assert.ok(r.errors.find(e => e.code === 'ENDPOINT_HARD_BLOCKED'));
|
||||
});
|
||||
|
||||
test('endpoint-validator: metadata.google.internal hostname PERMANENTLY blocked', () => {
|
||||
const r = validateOtlpEndpoint('http://metadata.google.internal/computeMetadata/v1',
|
||||
{ env: { VOYAGE_OTEL_ALLOW_PRIVATE: '1' } });
|
||||
assert.equal(r.valid, false);
|
||||
assert.ok(r.errors.find(e => e.code === 'ENDPOINT_HARD_BLOCKED'));
|
||||
});
|
||||
|
||||
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'));
|
||||
});
|
||||
|
||||
// IPv4-mapped IPv6 bypass (S21): `::ffff:a.b.c.d` literals render as
|
||||
// `::ffff:HHHH:HHHH` and route to the embedded IPv4. The host must be
|
||||
// canonicalized to its embedded IPv4 BEFORE classification, or loopback /
|
||||
// RFC-1918 / link-local / cloud-metadata guards are all bypassable over https.
|
||||
test('endpoint-validator: rejects IPv4-mapped IPv6 loopback [::ffff:127.0.0.1] (S21 bypass)', () => {
|
||||
const r = validateOtlpEndpoint('https://[::ffff:127.0.0.1]/v1/metrics', { env: {} });
|
||||
assert.equal(r.valid, false, 'IPv4-mapped loopback must be rejected');
|
||||
assert.ok(r.errors.find(e => e.code === 'ENDPOINT_LOOPBACK_REJECTED'));
|
||||
});
|
||||
|
||||
test('endpoint-validator: rejects IPv4-mapped IPv6 RFC-1918 [::ffff:192.168.0.5] (S21 bypass)', () => {
|
||||
const r = validateOtlpEndpoint('https://[::ffff:192.168.0.5]/v1/metrics', { env: {} });
|
||||
assert.equal(r.valid, false, 'IPv4-mapped RFC-1918 must be rejected');
|
||||
assert.ok(r.errors.find(e => e.code === 'ENDPOINT_RFC1918_REJECTED'));
|
||||
});
|
||||
|
||||
test('endpoint-validator: IPv4-mapped metadata [::ffff:169.254.169.254] HARD_BLOCKED even with opt-in (S21 bypass)', () => {
|
||||
const r = validateOtlpEndpoint('https://[::ffff:169.254.169.254]/latest/meta-data',
|
||||
{ env: { VOYAGE_OTEL_ALLOW_PRIVATE: '1' } });
|
||||
assert.equal(r.valid, false, 'IPv4-mapped cloud-metadata MUST stay hard-blocked');
|
||||
assert.ok(r.errors.find(e => e.code === 'ENDPOINT_HARD_BLOCKED'));
|
||||
});
|
||||
|
||||
test('endpoint-validator: accepts IPv4-mapped IPv6 public [::ffff:8.8.8.8] over https (no over-block)', () => {
|
||||
const r = validateOtlpEndpoint('https://[::ffff:8.8.8.8]/v1/metrics', { env: {} });
|
||||
assert.equal(r.valid, true, JSON.stringify(r.errors));
|
||||
assert.equal(r.parsed.isPrivate, false);
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
|
||||
// M5: 2045432 added intent_approved to the trekbrief allowlist and no test
|
||||
// guarded it — removing the line left the suite green.
|
||||
test('field-allowlist: trekbrief KEEPS intent_approved (veikart steg 1) and drops task / project_dir', () => {
|
||||
const record = {
|
||||
ts: '2026-09-23T10:00:00.000Z',
|
||||
task: 'private user prose',
|
||||
slug: 'dark-mode-toggle',
|
||||
project_dir: '/home/user/secret/project',
|
||||
intent_approved: true,
|
||||
};
|
||||
const out = applyFieldAllowlist(record, 'trekbrief');
|
||||
assert.equal(out.intent_approved, true);
|
||||
assert.equal(out.slug, 'dark-mode-toggle');
|
||||
assert.equal('task' in out, false);
|
||||
assert.equal('project_dir' in out, false);
|
||||
assert.equal(applyFieldAllowlist({ ...record, intent_approved: false }, 'trekbrief').intent_approved, false);
|
||||
});
|
||||
|
||||
test('field-allowlist: trekbrief DROPS the order fields (coordination metadata) and still KEEPS intent_approved', () => {
|
||||
const out = applyFieldAllowlist({
|
||||
ts: '2026-10-04T10:00:00.000Z', slug: 's', intent_approved: true,
|
||||
order_id: 'o-1', order_claimed_at: '2026-10-04T09:00:00Z', order_reason: null,
|
||||
}, 'trekbrief');
|
||||
for (const k of ['order_id', 'order_claimed_at', 'order_reason']) assert.equal(k in out, false, `${k} must never be exported`);
|
||||
assert.equal(out.intent_approved, true);
|
||||
});
|
||||
|
||||
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);
|
||||
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)');
|
||||
});
|
||||
|
||||
// ---- trekresearch allowlist: the `engine` field ----------------------------
|
||||
|
||||
test('field-allowlist: trekresearch INCLUDES engine, EXCLUDES question/project_dir/brief_path (two-sided)', () => {
|
||||
const record = {
|
||||
ts: '2026-08-09T12:00:00.000Z',
|
||||
question: 'which retrieval strategy survives contradiction?',
|
||||
mode: 'default',
|
||||
scope: 'both',
|
||||
engine: 'deep-research',
|
||||
slug: 'storm-upgrade',
|
||||
project_dir: '/Users/ktg/secret/project',
|
||||
brief_path: '/Users/ktg/secret/project/brief.md',
|
||||
dimensions: 4,
|
||||
agents_local: 7,
|
||||
agents_external: 4,
|
||||
gemini_used: false,
|
||||
confidence: 0.82,
|
||||
contradictions: 1,
|
||||
open_questions: 3,
|
||||
};
|
||||
const out = applyFieldAllowlist(record, 'trekresearch');
|
||||
// INCLUDED — low-cardinality label, emitted (trekresearch.md:533) and
|
||||
// promised in prose (:570-572); it was silently dropped before this pin.
|
||||
assert.equal('engine' in out, true, 'engine MUST be allowlisted — it is emitted and documented');
|
||||
assert.equal(out.engine, 'deep-research');
|
||||
assert.equal(out._schema_id, 'trekresearch');
|
||||
// EXCLUDED (CWE-212 boundary)
|
||||
assert.equal('question' in out, false, 'question MUST be stripped (prose, CWE-212)');
|
||||
assert.equal('project_dir' in out, false, 'project_dir MUST be stripped (path, CWE-212)');
|
||||
assert.equal('brief_path' in out, false, 'brief_path MUST be stripped (path, CWE-212)');
|
||||
});
|
||||
|
||||
test('field-allowlist: TREKRESEARCH_ALLOWED is frozen (drift-pin)', () => {
|
||||
assert.equal(Object.isFrozen(TREKRESEARCH_ALLOWED), true,
|
||||
'TREKRESEARCH_ALLOWED must be frozen — runtime mutation prevention');
|
||||
});
|
||||
|
||||
test('field-allowlist: null/undefined record handled safely', () => {
|
||||
assert.deepEqual(applyFieldAllowlist(null, 'trekplan'), {});
|
||||
assert.deepEqual(applyFieldAllowlist(undefined, 'trekplan'), {});
|
||||
});
|
||||
|
|
@ -1,134 +0,0 @@
|
|||
// tests/hooks/otel-export.test.mjs
|
||||
// SC #14: Stop-hook orchestration — opt-in via VOYAGE_EXPORT_MODE.
|
||||
// Fail-soft contract: any error → exit 0, [voyage] stderr, no Stop blocking.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { mkdtempSync, rmSync, existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { runHookWithEnv } from '../helpers/hook-helper.mjs';
|
||||
import { fastestWithin } from '../helpers/timing.mjs';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const HOOK_PATH = join(__dirname, '..', '..', 'hooks', 'scripts', 'otel-export.mjs');
|
||||
|
||||
function setupDataDir() {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'voyage-otel-data-'));
|
||||
// Seed minimal stats files
|
||||
writeFileSync(join(dir, 'trekplan-stats.jsonl'),
|
||||
JSON.stringify({ ts: '2026-05-09T08:00:00.000Z', slug: 'test', mode: 'default', codebase_files: 100, profile: 'premium' }) + '\n');
|
||||
return dir;
|
||||
}
|
||||
|
||||
test('SC #14: VOYAGE_EXPORT_MODE=off → silent exit 0, no file written', async () => {
|
||||
const dataDir = setupDataDir();
|
||||
try {
|
||||
const target = join(dataDir, 'voyage.prom');
|
||||
const r = await runHookWithEnv(HOOK_PATH, '{}', {
|
||||
VOYAGE_EXPORT_MODE: 'off',
|
||||
CLAUDE_PLUGIN_DATA: dataDir,
|
||||
});
|
||||
assert.equal(r.code, 0);
|
||||
assert.equal(existsSync(target), false, 'voyage.prom should NOT be written in off-mode');
|
||||
assert.equal(r.stderr, '', 'no stderr expected');
|
||||
} finally {
|
||||
rmSync(dataDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('SC #14: VOYAGE_EXPORT_MODE unset → silent exit 0 (default off behavior)', async () => {
|
||||
const dataDir = setupDataDir();
|
||||
try {
|
||||
const target = join(dataDir, 'voyage.prom');
|
||||
const r = await runHookWithEnv(HOOK_PATH, '{}', {
|
||||
CLAUDE_PLUGIN_DATA: dataDir,
|
||||
VOYAGE_EXPORT_MODE: '', // explicit empty (mimics unset)
|
||||
});
|
||||
assert.equal(r.code, 0);
|
||||
assert.equal(existsSync(target), false);
|
||||
} finally {
|
||||
rmSync(dataDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('SC #14: VOYAGE_EXPORT_MODE=textfile + valid CLAUDE_PLUGIN_DATA → writes voyage.prom', async () => {
|
||||
const dataDir = setupDataDir();
|
||||
try {
|
||||
const target = join(dataDir, 'voyage.prom');
|
||||
const r = await runHookWithEnv(HOOK_PATH, '{}', {
|
||||
VOYAGE_EXPORT_MODE: 'textfile',
|
||||
CLAUDE_PLUGIN_DATA: dataDir,
|
||||
});
|
||||
assert.equal(r.code, 0);
|
||||
assert.equal(existsSync(target), true, `voyage.prom should be written; stderr: ${r.stderr}`);
|
||||
const text = readFileSync(target, 'utf-8');
|
||||
assert.match(text, /# HELP /);
|
||||
assert.match(text, /# TYPE /);
|
||||
assert.match(text, /voyage_trekplan_codebase_files/);
|
||||
} finally {
|
||||
rmSync(dataDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('SC #14: VOYAGE_EXPORT_MODE=invalid → stderr [voyage] warning + exit 0 (NOT blocking)', async () => {
|
||||
const dataDir = setupDataDir();
|
||||
try {
|
||||
const r = await runHookWithEnv(HOOK_PATH, '{}', {
|
||||
VOYAGE_EXPORT_MODE: 'banana',
|
||||
CLAUDE_PLUGIN_DATA: dataDir,
|
||||
});
|
||||
assert.equal(r.code, 0, 'invalid mode MUST NOT block Stop');
|
||||
assert.match(r.stderr, /\[voyage\]/);
|
||||
assert.match(r.stderr, /banana/);
|
||||
} finally {
|
||||
rmSync(dataDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('SC #14: VOYAGE_EXPORT_MODE=otlp + invalid endpoint → stderr [voyage] warn + exit 0', async () => {
|
||||
const dataDir = setupDataDir();
|
||||
try {
|
||||
const r = await runHookWithEnv(HOOK_PATH, '{}', {
|
||||
VOYAGE_EXPORT_MODE: 'otlp',
|
||||
VOYAGE_OTEL_ENDPOINT: 'http://example.com/v1/metrics', // public-http rejected
|
||||
CLAUDE_PLUGIN_DATA: dataDir,
|
||||
});
|
||||
assert.equal(r.code, 0);
|
||||
assert.match(r.stderr, /\[voyage\]/);
|
||||
} finally {
|
||||
rmSync(dataDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('SC #14: tail-latency for textfile mode < 200ms (NFR)', async () => {
|
||||
const dataDir = setupDataDir();
|
||||
try {
|
||||
// The fastest of up to three cold runs against the same 800 ms limit
|
||||
// (tests/helpers/timing.mjs): load only adds time, so a real slowness
|
||||
// still fails every run, and one run slowed by a busy machine does not.
|
||||
const t = await fastestWithin(800, async () => {
|
||||
const start = performance.now();
|
||||
const r = await runHookWithEnv(HOOK_PATH, '{}', {
|
||||
VOYAGE_EXPORT_MODE: 'textfile',
|
||||
CLAUDE_PLUGIN_DATA: dataDir,
|
||||
});
|
||||
assert.equal(r.code, 0);
|
||||
return performance.now() - start;
|
||||
});
|
||||
// 200ms NFR with extra headroom for cold-start node spawn (~100ms typical)
|
||||
assert.ok(t.ok,
|
||||
`textfile export tail-latency too slow: fastest ${t.fastest.toFixed(0)}ms of ${t.runs.map((x) => x.toFixed(0)).join('/')}ms (NFR <200ms in-process; <800ms allowed for cold spawn)`);
|
||||
} finally {
|
||||
rmSync(dataDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('SC #14: missing CLAUDE_PLUGIN_DATA → silent exit 0', async () => {
|
||||
const r = await runHookWithEnv(HOOK_PATH, '{}', {
|
||||
VOYAGE_EXPORT_MODE: 'textfile',
|
||||
CLAUDE_PLUGIN_DATA: '',
|
||||
});
|
||||
assert.equal(r.code, 0);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue