fix(hooks): close F-5 path-traversal hardening in lib.mjs, split by field

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
This commit is contained in:
Kjell Tore Guttormsen 2026-08-09 10:04:33 +02:00
commit 736a1c0deb
4 changed files with 93 additions and 7 deletions

View file

@ -1,7 +1,8 @@
import { describe, it, afterEach } from 'node:test';
import assert from 'node:assert/strict';
import { join } from 'path';
import { writeFileSync } from 'fs';
import { writeFileSync, mkdtempSync, mkdirSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { runHook, setupTestDir, cleanupTestDir, readState, readJsonl } from './test-helper.mjs';
let dir;
@ -70,6 +71,28 @@ describe('session-start', () => {
});
});
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'