mirror of
https://github.com/affaan-m/ECC.git
synced 2026-08-20 22:42:22 +02:00
564 lines
18 KiB
JavaScript
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();
|