session_id becomes a raw filename segment in sessionStateFile(), so an unvalidated value could escape STATE_DIR via path traversal (verified with a failing test before the fix). Now allowlisted to ^[A-Za-z0-9_-]+$, with invalid values degrading to a fixed sentinel filename rather than blocking the hook. cwd is a base directory, not a segment, and every real value contains "/" — applying the same allowlist as the review's literal suggestion would reject all legitimate absolute paths and silently disable the project-level config override. initConfig() instead guards with isAbsolute(cwd) && no NUL byte. Both harness-supplied, not user-controlled: defense-in-depth, not a fix for an observed exploit. Tests added for the escape (red before fix, green after) and for the cwd regression (a normal absolute cwd still loads project config). Full resolution notes in docs/review-2026-06-20.md. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LSATejUPjGaxGnj9jkFQTo
177 lines
6.3 KiB
JavaScript
177 lines
6.3 KiB
JavaScript
// Unit tests for shared library constants and helpers.
|
|
// Sanity-checks that v1.2 thresholds and domain-stakes table are exported
|
|
// with the expected shape. Detector-level behaviour is covered in
|
|
// per-detector test files (user-info, validation-seeking, stakes-matrix).
|
|
|
|
import { test, describe, before, after } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { mkdtempSync, rmSync, writeFileSync } from 'fs';
|
|
import { join } from 'path';
|
|
import { tmpdir } from 'os';
|
|
|
|
// Allocate a fresh data dir before importing lib.mjs, so SESSIONS_LOG points
|
|
// at a sandbox path. The lib.mjs module captures CLAUDE_PLUGIN_DATA at import
|
|
// time, so the env var must be set first.
|
|
const TEST_DATA_DIR = mkdtempSync(join(tmpdir(), 'ia-lib-test-'));
|
|
process.env.CLAUDE_PLUGIN_DATA = TEST_DATA_DIR;
|
|
|
|
const {
|
|
TIER1_TURN_THRESHOLD,
|
|
TIER2_SESSION_THRESHOLD,
|
|
THRESHOLD_VALSEEK_FLAGS,
|
|
DOMAIN_STAKES,
|
|
HIGH_SYCOPHANCY_DOMAINS,
|
|
HIGH_STAKES_DOMAINS,
|
|
INFO_DOMAINS,
|
|
SESSIONS_LOG,
|
|
STATE_DIR,
|
|
readRecentEndRecords,
|
|
sessionStateFile,
|
|
} = await import('../hooks/scripts/lib.mjs');
|
|
|
|
after(() => {
|
|
rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
|
});
|
|
|
|
describe('v1.2 thresholds', () => {
|
|
test('tier-1 turn threshold is 15', () => {
|
|
assert.equal(TIER1_TURN_THRESHOLD, 15);
|
|
});
|
|
|
|
test('tier-2 session threshold is 3', () => {
|
|
assert.equal(TIER2_SESSION_THRESHOLD, 3);
|
|
});
|
|
|
|
test('valseek high-stakes flag threshold is 3', () => {
|
|
assert.equal(THRESHOLD_VALSEEK_FLAGS, 3);
|
|
});
|
|
});
|
|
|
|
describe('DOMAIN_STAKES table', () => {
|
|
test('default weight is 1.0', () => {
|
|
assert.equal(DOMAIN_STAKES.default, 1.0);
|
|
});
|
|
|
|
test('high-stakes domains weighted 1.5', () => {
|
|
assert.equal(DOMAIN_STAKES.legal, 1.5);
|
|
assert.equal(DOMAIN_STAKES.parenting, 1.5);
|
|
assert.equal(DOMAIN_STAKES.health, 1.5);
|
|
assert.equal(DOMAIN_STAKES.financial, 1.5);
|
|
});
|
|
|
|
test('high-sycophancy domains weighted between 1.2 and 1.3', () => {
|
|
assert.equal(DOMAIN_STAKES.relationship, 1.3);
|
|
assert.equal(DOMAIN_STAKES.spirituality, 1.2);
|
|
});
|
|
|
|
test('table is frozen (immutable)', () => {
|
|
assert.equal(Object.isFrozen(DOMAIN_STAKES), true);
|
|
});
|
|
|
|
test('uses singular domain identifiers (relationship, not relationships)', () => {
|
|
assert.equal(DOMAIN_STAKES.relationship, 1.3);
|
|
assert.equal(DOMAIN_STAKES.relationships, undefined);
|
|
});
|
|
});
|
|
|
|
describe('domain classification arrays', () => {
|
|
test('HIGH_SYCOPHANCY_DOMAINS contains relationship and spirituality', () => {
|
|
assert.deepEqual([...HIGH_SYCOPHANCY_DOMAINS], ['relationship', 'spirituality']);
|
|
assert.equal(Object.isFrozen(HIGH_SYCOPHANCY_DOMAINS), true);
|
|
});
|
|
|
|
test('HIGH_STAKES_DOMAINS contains legal, parenting, health, financial', () => {
|
|
assert.deepEqual([...HIGH_STAKES_DOMAINS], ['legal', 'parenting', 'health', 'financial']);
|
|
assert.equal(Object.isFrozen(HIGH_STAKES_DOMAINS), true);
|
|
});
|
|
|
|
test('INFO_DOMAINS adds professional to HIGH_STAKES_DOMAINS', () => {
|
|
assert.deepEqual(
|
|
[...INFO_DOMAINS],
|
|
['legal', 'parenting', 'health', 'financial', 'professional']
|
|
);
|
|
assert.equal(Object.isFrozen(INFO_DOMAINS), true);
|
|
});
|
|
});
|
|
|
|
describe('readRecentEndRecords', () => {
|
|
function writeFixture(records) {
|
|
const lines = records.map(r => JSON.stringify(r)).join('\n') + '\n';
|
|
writeFileSync(SESSIONS_LOG, lines);
|
|
}
|
|
|
|
test('returns N most recent end records in chronological order', () => {
|
|
writeFixture([
|
|
{ session_id: 'a', start: '2026-05-01T10:00:00Z' }, // start record (no duration)
|
|
{ session_id: 'a', start: '2026-05-01T10:00:00Z', end: '2026-05-01T10:30:00Z', duration_min: 30 },
|
|
{ session_id: 'b', start: '2026-05-01T11:00:00Z' },
|
|
{ session_id: 'b', start: '2026-05-01T11:00:00Z', end: '2026-05-01T11:45:00Z', duration_min: 45 },
|
|
{ session_id: 'c', start: '2026-05-01T12:00:00Z', end: '2026-05-01T12:20:00Z', duration_min: 20 },
|
|
{ session_id: 'd', start: '2026-05-01T13:00:00Z', end: '2026-05-01T13:50:00Z', duration_min: 50 },
|
|
]);
|
|
|
|
const recent = readRecentEndRecords(3);
|
|
assert.equal(recent.length, 3);
|
|
assert.equal(recent[0].session_id, 'b');
|
|
assert.equal(recent[1].session_id, 'c');
|
|
assert.equal(recent[2].session_id, 'd');
|
|
});
|
|
|
|
test('returns fewer than N when not enough end records exist', () => {
|
|
writeFixture([
|
|
{ session_id: 'a', start: '2026-05-01T10:00:00Z', end: '2026-05-01T10:30:00Z', duration_min: 30 },
|
|
]);
|
|
const recent = readRecentEndRecords(5);
|
|
assert.equal(recent.length, 1);
|
|
assert.equal(recent[0].session_id, 'a');
|
|
});
|
|
|
|
test('skips malformed JSON lines', () => {
|
|
const goodA = JSON.stringify({ session_id: 'a', duration_min: 1 });
|
|
const goodB = JSON.stringify({ session_id: 'b', duration_min: 2 });
|
|
writeFileSync(SESSIONS_LOG, `${goodA}\nnot json\n${goodB}\n`);
|
|
const recent = readRecentEndRecords(5);
|
|
assert.equal(recent.length, 2);
|
|
assert.equal(recent[0].session_id, 'a');
|
|
assert.equal(recent[1].session_id, 'b');
|
|
});
|
|
|
|
test('empty file returns []', () => {
|
|
writeFileSync(SESSIONS_LOG, '');
|
|
assert.deepEqual(readRecentEndRecords(3), []);
|
|
});
|
|
|
|
test('missing file returns []', () => {
|
|
rmSync(SESSIONS_LOG, { force: true });
|
|
assert.deepEqual(readRecentEndRecords(3), []);
|
|
});
|
|
|
|
test('non-positive N returns []', () => {
|
|
writeFixture([{ session_id: 'a', duration_min: 1 }]);
|
|
assert.deepEqual(readRecentEndRecords(0), []);
|
|
assert.deepEqual(readRecentEndRecords(-1), []);
|
|
});
|
|
});
|
|
|
|
describe('sessionStateFile — path-traversal allowlist (F-5)', () => {
|
|
test('normal UUID-shaped session_id passes through unchanged', () => {
|
|
const f = sessionStateFile('a1b2c3d4-e5f6-7890-abcd-ef1234567890');
|
|
assert.equal(f, join(STATE_DIR, 'a1b2c3d4-e5f6-7890-abcd-ef1234567890.json'));
|
|
});
|
|
|
|
test('relative path-traversal session_id is rejected, stays inside STATE_DIR', () => {
|
|
const f = sessionStateFile('../../escape');
|
|
assert.ok(f.startsWith(STATE_DIR));
|
|
assert.ok(!f.includes('..'));
|
|
});
|
|
|
|
test('absolute-path session_id is rejected, stays inside STATE_DIR', () => {
|
|
const f = sessionStateFile('/etc/passwd');
|
|
assert.ok(f.startsWith(STATE_DIR));
|
|
});
|
|
|
|
test('session_id containing a path separator is rejected, stays inside STATE_DIR', () => {
|
|
const f = sessionStateFile('a/b');
|
|
assert.ok(f.startsWith(STATE_DIR));
|
|
});
|
|
});
|