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
161 lines
6.9 KiB
JavaScript
161 lines
6.9 KiB
JavaScript
import { describe, it, afterEach } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { join } from 'path';
|
|
import { writeFileSync, mkdtempSync, mkdirSync, rmSync } from 'fs';
|
|
import { tmpdir } from 'os';
|
|
import { runHook, setupTestDir, cleanupTestDir, readState, readJsonl } from './test-helper.mjs';
|
|
|
|
let dir;
|
|
afterEach(() => { if (dir) cleanupTestDir(dir); });
|
|
|
|
describe('session-start', () => {
|
|
it('creates state file and emits context', () => {
|
|
dir = setupTestDir();
|
|
const out = runHook('session-start.mjs', { session_id: 's1', cwd: '/tmp' }, dir);
|
|
assert.equal(out.continue, true);
|
|
assert.equal(out.hookSpecificOutput.hookEventName, 'SessionStart');
|
|
assert.ok(out.hookSpecificOutput.additionalContext.includes('Interaction Awareness is active'));
|
|
const state = readState(dir, 's1');
|
|
assert.ok(state);
|
|
assert.equal(state.tool_count, 0);
|
|
assert.equal(state.edit_count, 0);
|
|
assert.equal(state.dep_flags, 0);
|
|
});
|
|
|
|
it('writes start record to sessions.jsonl', () => {
|
|
dir = setupTestDir();
|
|
runHook('session-start.mjs', { session_id: 's2', cwd: '/tmp' }, dir);
|
|
const records = readJsonl(join(dir, 'sessions.jsonl'));
|
|
assert.equal(records.length, 1);
|
|
assert.equal(records[0].session_id, 's2');
|
|
assert.ok('hour' in records[0]);
|
|
assert.ok('is_late_night' in records[0]);
|
|
});
|
|
|
|
it('state has correct initial fields', () => {
|
|
dir = setupTestDir();
|
|
runHook('session-start.mjs', { session_id: 's3', cwd: '/tmp' }, dir);
|
|
const state = readState(dir, 's3');
|
|
assert.equal(state.burst_count, 0);
|
|
assert.equal(state.last_event_epoch, 0);
|
|
assert.equal(state.last_warning_epoch, 0);
|
|
assert.ok(state.start_epoch > 0);
|
|
assert.ok(state.start_iso.length > 0);
|
|
});
|
|
|
|
it('returns continue with no side effects when session_id missing', () => {
|
|
dir = setupTestDir();
|
|
const out = runHook('session-start.mjs', { cwd: '/tmp' }, dir);
|
|
assert.equal(out.continue, true);
|
|
assert.ok(!out.hookSpecificOutput);
|
|
});
|
|
|
|
it('initializes pushback_count and domain_context fields (v1.1.0)', () => {
|
|
dir = setupTestDir();
|
|
runHook('session-start.mjs', { session_id: 's4', cwd: '/tmp' }, dir);
|
|
const state = readState(dir, 's4');
|
|
assert.ok(state);
|
|
assert.equal(state.pushback_count, 0);
|
|
assert.equal(state.domain_context, null);
|
|
});
|
|
|
|
it('initializes v1.2 user-info, valseek, turn_count fields', () => {
|
|
dir = setupTestDir();
|
|
runHook('session-start.mjs', { session_id: 's4b', cwd: '/tmp' }, dir);
|
|
const state = readState(dir, 's4b');
|
|
assert.equal(state.user_info_class, null);
|
|
assert.deepEqual(state.user_info_flags, { yes_people: 0, yes_digital: 0, no: 0 });
|
|
assert.equal(state.turn_count, 0);
|
|
assert.equal(state.valseek_count, 0);
|
|
assert.equal(state.valseek_flag, 0);
|
|
});
|
|
});
|
|
|
|
describe('initConfig — cwd path handling (F-5 regression guard)', () => {
|
|
let projectDir;
|
|
|
|
afterEach(() => { if (projectDir) rmSync(projectDir, { recursive: true, force: true }); });
|
|
|
|
it('still loads project-level config for a normal absolute cwd', () => {
|
|
dir = setupTestDir();
|
|
projectDir = mkdtempSync(join(tmpdir(), 'ia-project-'));
|
|
mkdirSync(join(projectDir, '.claude'), { recursive: true });
|
|
writeFileSync(
|
|
join(projectDir, '.claude', 'ai-psychosis.local.md'),
|
|
'---\nlayer2: false\n---\n'
|
|
);
|
|
|
|
const out = runHook('session-start.mjs', { session_id: 's-cfg', cwd: projectDir }, dir);
|
|
// layer2 disabled by the project config -> requireLayer(2) short-circuits
|
|
// before any hookSpecificOutput is emitted.
|
|
assert.equal(out.continue, true);
|
|
assert.ok(!out.hookSpecificOutput);
|
|
});
|
|
});
|
|
|
|
// --- Tier-2 cross-session alert ---
|
|
//
|
|
// Fires at SessionStart when last 3 end records all have user_info_class='no'
|
|
// AND each session had at least one HIGH_STAKES_DOMAINS hit.
|
|
|
|
function writeFixture(dir, records) {
|
|
const lines = records.map(r => JSON.stringify(r)).join('\n') + '\n';
|
|
writeFileSync(join(dir, 'sessions.jsonl'), lines);
|
|
}
|
|
|
|
describe('tier-2 cross-session isolation alert', () => {
|
|
it('fires when 3 prior end records all show no + high-stakes', () => {
|
|
dir = setupTestDir();
|
|
writeFixture(dir, [
|
|
{ session_id: 'p1', duration_min: 30, user_info_class: 'no', domain_context: ['legal'] },
|
|
{ session_id: 'p2', duration_min: 25, user_info_class: 'no', domain_context: ['health'] },
|
|
{ session_id: 'p3', duration_min: 40, user_info_class: 'no', domain_context: ['parenting', 'financial'] },
|
|
]);
|
|
const out = runHook('session-start.mjs', { session_id: 'snew', cwd: '/tmp' }, dir);
|
|
assert.match(out.hookSpecificOutput.additionalContext, /tier-2/);
|
|
});
|
|
|
|
it('does NOT fire when only 2 prior "no" records exist', () => {
|
|
dir = setupTestDir();
|
|
writeFixture(dir, [
|
|
{ session_id: 'p1', duration_min: 30, user_info_class: 'no', domain_context: ['legal'] },
|
|
{ session_id: 'p2', duration_min: 30, user_info_class: 'no', domain_context: ['health'] },
|
|
]);
|
|
const out = runHook('session-start.mjs', { session_id: 'snew2', cwd: '/tmp' }, dir);
|
|
const text = out.hookSpecificOutput.additionalContext;
|
|
assert.ok(!/tier-2/.test(text), 'tier-2 must require N consecutive sessions');
|
|
});
|
|
|
|
it('does NOT fire when one record has yes_people class', () => {
|
|
dir = setupTestDir();
|
|
writeFixture(dir, [
|
|
{ session_id: 'p1', duration_min: 30, user_info_class: 'no', domain_context: ['legal'] },
|
|
{ session_id: 'p2', duration_min: 30, user_info_class: 'yes_people', domain_context: ['health'] },
|
|
{ session_id: 'p3', duration_min: 30, user_info_class: 'no', domain_context: ['financial'] },
|
|
]);
|
|
const out = runHook('session-start.mjs', { session_id: 'snew3', cwd: '/tmp' }, dir);
|
|
assert.ok(!/tier-2/.test(out.hookSpecificOutput.additionalContext));
|
|
});
|
|
|
|
it('does NOT fire when any session is in low-stakes domain', () => {
|
|
dir = setupTestDir();
|
|
writeFixture(dir, [
|
|
{ session_id: 'p1', duration_min: 30, user_info_class: 'no', domain_context: ['legal'] },
|
|
{ session_id: 'p2', duration_min: 30, user_info_class: 'no', domain_context: ['consumer'] },
|
|
{ session_id: 'p3', duration_min: 30, user_info_class: 'no', domain_context: ['health'] },
|
|
]);
|
|
const out = runHook('session-start.mjs', { session_id: 'snew4', cwd: '/tmp' }, dir);
|
|
assert.ok(!/tier-2/.test(out.hookSpecificOutput.additionalContext));
|
|
});
|
|
|
|
it('handles v1.1.0 records with string domain_context (backward compat)', () => {
|
|
dir = setupTestDir();
|
|
writeFixture(dir, [
|
|
{ session_id: 'p1', duration_min: 30, user_info_class: 'no', domain_context: 'health' }, // string shape
|
|
{ session_id: 'p2', duration_min: 30, user_info_class: 'no', domain_context: ['legal'] },
|
|
{ session_id: 'p3', duration_min: 30, user_info_class: 'no', domain_context: ['parenting'] },
|
|
]);
|
|
const out = runHook('session-start.mjs', { session_id: 'snew5', cwd: '/tmp' }, dir);
|
|
assert.match(out.hookSpecificOutput.additionalContext, /tier-2/);
|
|
});
|
|
});
|