Files
ECC/tests/hooks/hookify-loader.test.js

564 lines
18 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('simple file patterns preserve the documented file_path matcher', () => {
const loaded = validateRule({
name: 'protect-env-files',
enabled: true,
event: 'file',
action: 'block',
pattern: '\\.env$',
}, 'Do not edit environment files.', 'hookify.protect-env-files.local.md');
assert.deepStrictEqual(loaded.conditions, [{
field: 'file_path',
operator: 'regex_match',
pattern: '\\.env$',
}]);
})) 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 condition list items after the conditions block is closed', () => {
assert.throws(
() => parseFrontmatter([
'name: misplaced-condition',
'enabled: true',
'event: file',
'conditions:',
' - field: file_path',
' operator: contains',
' pattern: src/',
'action: block',
' - field: content',
' operator: contains',
' pattern: API_KEY',
].join('\n')),
/unsupported YAML structure/
);
})) passed++; else failed++;
if (test('rejects model-facing messages with bidi or invisible controls', () => {
for (const message of ['Looks safe\u202E.gnirts', 'Invisible\uFEFFjoiner']) {
assert.throws(
() => validateRule({
name: 'bidi-message',
enabled: true,
event: 'bash',
pattern: 'deploy',
}, message, 'hookify.bidi-message.local.md'),
/message contains unsafe invisible or bidirectional characters/
);
}
})) 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.strictEqual(result.rules.length, LIMITS.maxRuleFiles - 2);
assert.ok(result.totalBytes <= LIMITS.maxTotalBytes);
assert.ok(result.diagnostics.some(item =>
item.code === 'HOOKIFY_RULE_LIMIT' &&
item.message.includes('rule or directory entry count limit reached')
));
assert.ok(result.diagnostics.some(item =>
item.code === 'HOOKIFY_RULE_LIMIT' &&
item.message.includes('file exceeds byte limit')
));
assert.ok(result.diagnostics.some(item =>
item.code === 'HOOKIFY_RULE_INVALID' &&
item.message.includes('pattern-too-long')
));
});
})) 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('reports descriptor read failures separately from schema errors', () => {
withProject(({ claudeDir }) => {
const originalReadSync = fs.readSync;
let readAttempted = false;
writeRule(
claudeDir,
'hookify.read-fails.local.md',
[
'name: read-fails',
'enabled: true',
'event: bash',
'pattern: safe',
].join('\n')
);
try {
fs.readSync = function readSyncFails() {
readAttempted = true;
const error = new Error('sensitive device detail');
error.code = 'EIO';
throw error;
};
const result = loadRuleFile({
claudeDir,
fileName: 'hookify.read-fails.local.md',
remainingTotalBytes: LIMITS.maxTotalBytes,
expectedRealDirectory: fs.realpathSync(claudeDir),
});
assert.strictEqual(readAttempted, true);
assert.strictEqual(result.rule, null);
assert.strictEqual(result.diagnostic.code, 'HOOKIFY_RULE_READ_FAILED');
assert.ok(!result.diagnostic.message.includes('sensitive device detail'));
} finally {
fs.readSync = originalReadSync;
}
});
})) 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();