Files
ECC/tests/hooks/hookify-loader.test.js
T
Affaan Mustafa 396fadc784 fix: pin Hookify rules before path inspection
Open rule files before trusting path metadata, compare the descriptor identity to the live path, and only then validate containment and read bytes. Replace the unsafe temp-file race simulation with an open-first descriptor/path-swap regression.
2026-07-26 05:16:06 -04:00

465 lines
15 KiB
JavaScript

/**
* Unit tests for the bounded Hookify rule loader.
*/
'use strict';
const assert = require('assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const {
LIMITS,
extractDocument,
loadRuleFile,
loadRules,
parseFrontmatter,
validateRule,
} = require('../../scripts/hooks/hookify-loader');
function test(name, fn) {
try {
fn();
console.log(` ✓ ${name}`);
return true;
} catch (error) {
console.log(` ✗ ${name}`);
console.log(` Error: ${error.message}`);
return false;
}
}
function withProject(fn) {
const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-hookify-loader-'));
const claudeDir = path.join(projectRoot, '.claude');
fs.mkdirSync(claudeDir);
try {
return fn({ projectRoot, claudeDir });
} finally {
fs.rmSync(projectRoot, { recursive: true, force: true });
}
}
function writeRule(claudeDir, fileName, frontmatter, message = 'Rule matched.') {
const source = `---\n${frontmatter}\n---\n${message}\n`;
fs.writeFileSync(path.join(claudeDir, fileName), source);
}
function runTests() {
console.log('\n=== Hookify loader tests ===\n');
let passed = 0;
let failed = 0;
if (test('loads enabled pattern and condition rules for the requested event', () => {
withProject(({ projectRoot, claudeDir }) => {
writeRule(
claudeDir,
'hookify.block-danger.local.md',
[
'name: block-danger',
'enabled: true',
'event: bash',
'action: block',
'pattern: "rm\\\\s+-rf"',
].join('\n'),
'Do not recursively remove this path.'
);
writeRule(
claudeDir,
'hookify.warn-secrets.local.md',
[
'name: warn-secrets',
'enabled: true',
'event: all',
'action: warn',
'tool_matcher: Write|Edit',
'conditions:',
' - field: file_path',
' operator: ends_with',
' pattern: .env',
' - field: content',
' operator: contains',
' pattern: API_KEY',
].join('\n'),
'Keep credentials out of source control.'
);
writeRule(
claudeDir,
'hookify.disabled.local.md',
[
'name: disabled',
'enabled: false',
'event: bash',
'pattern: anything',
].join('\n')
);
const result = loadRules({ projectRoot, event: 'bash' });
assert.deepStrictEqual(result.rules.map(rule => rule.name), [
'block-danger',
'warn-secrets',
]);
assert.deepStrictEqual(result.rules[0].conditions, [{
field: 'command',
operator: 'regex_match',
pattern: 'rm\\s+-rf',
}]);
assert.strictEqual(result.rules[1].conditions.length, 2);
assert.deepStrictEqual(result.diagnostics, []);
});
})) passed++; else failed++;
if (test('rejects unknown fields, ambiguous matchers, invalid operators, and event-incompatible fields', () => {
withProject(({ projectRoot, claudeDir }) => {
writeRule(
claudeDir,
'hookify.unknown.local.md',
[
'name: unknown',
'enabled: true',
'event: bash',
'pattern: ls',
'surprise: nope',
].join('\n')
);
writeRule(
claudeDir,
'hookify.ambiguous.local.md',
[
'name: ambiguous',
'enabled: true',
'event: bash',
'pattern: ls',
'conditions:',
' - field: command',
' operator: contains',
' pattern: npm',
].join('\n')
);
writeRule(
claudeDir,
'hookify.operator.local.md',
[
'name: operator',
'enabled: true',
'event: file',
'conditions:',
' - field: file_path',
' operator: execute',
' pattern: .env',
].join('\n')
);
writeRule(
claudeDir,
'hookify.field.local.md',
[
'name: field',
'enabled: true',
'event: prompt',
'conditions:',
' - field: command',
' operator: contains',
' pattern: deploy',
].join('\n')
);
const result = loadRules({ projectRoot, event: null });
assert.deepStrictEqual(result.rules, []);
assert.strictEqual(result.diagnostics.length, 4);
assert.ok(result.diagnostics.every(item => item.code === 'HOOKIFY_RULE_INVALID'));
assert.ok(result.diagnostics.every(item => !item.message.includes(projectRoot)));
});
})) passed++; else failed++;
if (test('rejects traversal names, symlinked files, and a symlinked .claude directory', () => {
withProject(({ projectRoot, claudeDir }) => {
const outside = path.join(projectRoot, 'outside.md');
fs.writeFileSync(outside, [
'---',
'name: outside',
'enabled: true',
'event: bash',
'pattern: pwd',
'---',
'Outside.',
].join('\n'));
fs.symlinkSync(outside, path.join(claudeDir, 'hookify.link.local.md'));
const direct = loadRuleFile({
claudeDir,
fileName: '../outside.md',
remainingTotalBytes: LIMITS.maxTotalBytes,
});
assert.strictEqual(direct.rule, null);
assert.strictEqual(direct.diagnostic.code, 'HOOKIFY_RULE_FILE_UNSAFE');
const linkedFile = loadRules({ projectRoot, event: 'bash' });
assert.deepStrictEqual(linkedFile.rules, []);
assert.ok(linkedFile.diagnostics.some(item => item.code === 'HOOKIFY_RULE_FILE_UNSAFE'));
fs.rmSync(claudeDir, { recursive: true, force: true });
fs.mkdirSync(path.join(projectRoot, 'elsewhere'));
fs.symlinkSync(path.join(projectRoot, 'elsewhere'), claudeDir);
const linkedDirectory = loadRules({ projectRoot, event: 'bash' });
assert.deepStrictEqual(linkedDirectory.rules, []);
assert.strictEqual(linkedDirectory.diagnostics[0].code, 'HOOKIFY_RULE_DIRECTORY_UNSAFE');
});
})) passed++; else failed++;
if (test('opens a rule descriptor before path inspection and rejects a later symlink swap', () => {
withProject(({ projectRoot, claudeDir }) => {
const fileName = 'hookify.race.local.md';
const rulePath = path.join(claudeDir, fileName);
const backupPath = path.join(claudeDir, 'safe-rule.backup');
const outsidePath = path.join(projectRoot, 'outside-rule.md');
writeRule(
claudeDir,
fileName,
'name: safe-rule\nenabled: true\nevent: bash\npattern: safe'
);
fs.writeFileSync(outsidePath, [
'---',
'name: raced-rule',
'enabled: true',
'event: bash',
'pattern: raced',
'---',
'This outside rule must never be loaded.',
].join('\n'));
const originalFstatSync = fs.fstatSync;
const originalLstatSync = fs.lstatSync;
let descriptorOpened = false;
let inspectedBeforeOpen = false;
let swapped = false;
fs.fstatSync = function markDescriptorOpen(descriptor, ...args) {
descriptorOpened = true;
return originalFstatSync.call(fs, descriptor, ...args);
};
fs.lstatSync = function inspectWithSwap(target, ...args) {
if (target === rulePath && !descriptorOpened) {
inspectedBeforeOpen = true;
} else if (!swapped && target === rulePath) {
swapped = true;
fs.renameSync(rulePath, backupPath);
fs.symlinkSync(outsidePath, rulePath);
const linkStat = originalLstatSync.call(fs, target, ...args);
fs.unlinkSync(rulePath);
fs.renameSync(backupPath, rulePath);
return linkStat;
}
return originalLstatSync.call(fs, target, ...args);
};
try {
const result = loadRuleFile({
claudeDir,
fileName,
remainingTotalBytes: LIMITS.maxTotalBytes,
expectedRealDirectory: fs.realpathSync(claudeDir),
});
assert.strictEqual(result.rule, null);
assert.strictEqual(result.diagnostic.code, 'HOOKIFY_RULE_FILE_UNSAFE');
assert.strictEqual(inspectedBeforeOpen, false);
assert.strictEqual(swapped, true);
} finally {
fs.fstatSync = originalFstatSync;
fs.lstatSync = originalLstatSync;
if (fs.existsSync(backupPath) && !fs.existsSync(rulePath)) {
fs.renameSync(backupPath, rulePath);
}
}
});
})) passed++; else failed++;
if (test('caps rule count, individual bytes, total bytes, and pattern length', () => {
withProject(({ projectRoot, claudeDir }) => {
for (let index = 0; index < LIMITS.maxRuleFiles + 2; index += 1) {
writeRule(
claudeDir,
`hookify.rule-${String(index).padStart(3, '0')}.local.md`,
[
`name: rule-${index}`,
'enabled: true',
'event: bash',
'pattern: safe',
].join('\n')
);
}
writeRule(
claudeDir,
'hookify.pattern-too-long.local.md',
[
'name: pattern-too-long',
'enabled: true',
'event: bash',
`pattern: ${'x'.repeat(LIMITS.maxPatternLength + 1)}`,
].join('\n')
);
fs.writeFileSync(
path.join(claudeDir, 'hookify.file-too-large.local.md'),
Buffer.alloc(LIMITS.maxFileBytes + 1, 0x61)
);
const result = loadRules({ projectRoot, event: 'bash' });
assert.ok(result.rules.length <= LIMITS.maxRuleFiles);
assert.ok(result.totalBytes <= LIMITS.maxTotalBytes);
assert.ok(result.diagnostics.some(item => item.code === 'HOOKIFY_RULE_LIMIT'));
});
})) passed++; else failed++;
if (test('rejects malformed frontmatter and invalid UTF-8 without throwing', () => {
withProject(({ projectRoot, claudeDir }) => {
fs.writeFileSync(
path.join(claudeDir, 'hookify.frontmatter.local.md'),
'---\nname: broken\nenabled: true\n'
);
fs.writeFileSync(
path.join(claudeDir, 'hookify.encoding.local.md'),
Buffer.from([0xff, 0xfe, 0xfd])
);
const result = loadRules({ projectRoot, event: 'bash' });
assert.deepStrictEqual(result.rules, []);
assert.strictEqual(result.diagnostics.length, 2);
assert.ok(result.diagnostics.every(item => item.code === 'HOOKIFY_RULE_INVALID'));
});
})) passed++; else failed++;
if (test('counts malformed file reads against the hard total byte budget', () => {
withProject(({ projectRoot, claudeDir }) => {
for (let index = 0; index < 12; index += 1) {
writeRule(
claudeDir,
`hookify.invalid-${String(index).padStart(2, '0')}.local.md`,
[
`name: invalid-${index}`,
'enabled: maybe',
'event: bash',
'pattern: anything',
].join('\n'),
'x'.repeat(60 * 1024)
);
}
const result = loadRules({ projectRoot, event: 'bash' });
assert.deepStrictEqual(result.rules, []);
assert.ok(result.totalBytes <= LIMITS.maxTotalBytes);
assert.ok(result.diagnostics.some(item =>
item.code === 'HOOKIFY_RULE_LIMIT' &&
item.message.includes('total rule byte limit')
));
});
})) passed++; else failed++;
if (test('strict frontmatter parser handles supported quoting and rejects YAML expansion', () => {
const parsed = parseFrontmatter([
'# a full-line comment is allowed',
"name: 'quoted-rule'",
'enabled: true',
'event: file',
'action: warn',
'conditions:',
' - field: file_path',
' operator: ends_with',
" pattern: '.env'",
].join('\n'));
assert.strictEqual(parsed.name, 'quoted-rule');
assert.strictEqual(parsed.enabled, true);
assert.deepStrictEqual(parsed.conditions[0], {
field: 'file_path',
operator: 'ends_with',
pattern: '.env',
});
for (const source of [
'name: "unterminated',
"name: 'unterminated",
'name: first\nname: second',
'conditions: inline',
'conditions:\n - unknown: value',
'conditions:\n field: command',
'name: &anchor value',
]) {
assert.throws(() => parseFrontmatter(source));
}
assert.throws(() => extractDocument('no frontmatter'));
assert.throws(() => extractDocument('---\nname: no-close'));
})) passed++; else failed++;
if (test('strict schema defaults warn and rejects invalid names, matchers, messages, and condition counts', () => {
const base = {
name: 'valid-rule',
enabled: true,
event: 'bash',
pattern: 'safe',
};
const valid = validateRule(base, 'Message.', 'hookify.valid-rule.local.md');
assert.strictEqual(valid.action, 'warn');
assert.strictEqual(valid.toolMatcher, null);
for (const frontmatter of [
{ ...base, name: 'Not Kebab' },
{ ...base, enabled: 'true' },
{ ...base, event: 'unknown' },
{ ...base, action: 'execute' },
{ ...base, tool_matcher: 'Bash |Write' },
{ ...base, tool_matcher: '!' },
{ ...base, pattern: undefined },
{ name: 'conditionless', enabled: true, event: 'bash', conditions: [] },
{
name: 'too-many',
enabled: true,
event: 'bash',
conditions: Array.from(
{ length: LIMITS.maxConditionCount + 1 },
() => ({ field: 'command', operator: 'contains', pattern: 'x' })
),
},
]) {
assert.throws(() =>
validateRule(frontmatter, 'Message.', 'hookify.invalid.local.md')
);
}
assert.throws(() => validateRule(base, '\u0000', 'hookify.invalid.local.md'));
assert.throws(() =>
validateRule(base, 'x'.repeat(LIMITS.maxMessageBytes + 1), 'hookify.invalid.local.md')
);
})) passed++; else failed++;
if (test('missing rule directories and non-rule files are harmless', () => {
const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-hookify-empty-'));
try {
assert.deepStrictEqual(loadRules({ projectRoot, event: 'bash' }), {
rules: [],
diagnostics: [],
totalBytes: 0,
});
fs.mkdirSync(path.join(projectRoot, '.claude'));
fs.writeFileSync(path.join(projectRoot, '.claude', 'settings.json'), '{}');
assert.deepStrictEqual(loadRules({ projectRoot, event: 'bash' }).rules, []);
fs.rmSync(path.join(projectRoot, '.claude'), { recursive: true, force: true });
fs.writeFileSync(path.join(projectRoot, '.claude'), 'not a directory');
assert.strictEqual(
loadRules({ projectRoot, event: 'bash' }).diagnostics[0].code,
'HOOKIFY_RULE_DIRECTORY_UNSAFE'
);
} finally {
fs.rmSync(projectRoot, { recursive: true, force: true });
}
})) passed++; else failed++;
console.log(`\nPassed: ${passed}`);
console.log(`Failed: ${failed}`);
process.exit(failed > 0 ? 1 : 0);
}
runTests();