Files
ECC/scripts/hooks/hookify-loader.js

639 lines
18 KiB
JavaScript

#!/usr/bin/env node
/**
* Bounded, project-local loader for `.claude/hookify.*.local.md` rules.
*/
'use strict';
const fs = require('fs');
const path = require('path');
const { TextDecoder } = require('util');
const LIMITS = Object.freeze({
maxConditionCount: 16,
maxDirectoryEntries: 256,
maxFileBytes: 64 * 1024,
maxMessageBytes: 4096,
maxPatternLength: 512,
maxRuleFiles: 64,
maxToolMatcherLength: 256,
maxTotalBytes: 512 * 1024,
});
const FILE_NAME_PATTERN = /^hookify\.[A-Za-z0-9][A-Za-z0-9._-]{0,95}\.local\.md$/;
const RULE_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
const TOOL_NAME_PATTERN = /^[A-Za-z][A-Za-z0-9_.:-]{0,63}$/;
const TOP_LEVEL_FIELDS = new Set([
'name',
'enabled',
'event',
'pattern',
'conditions',
'action',
'tool_matcher',
]);
const CONDITION_FIELDS = new Set(['field', 'operator', 'pattern']);
const EVENTS = new Set(['bash', 'file', 'stop', 'prompt', 'all']);
const ACTIONS = new Set(['warn', 'block']);
const OPERATORS = new Set([
'regex_match',
'contains',
'equals',
'not_contains',
'starts_with',
'ends_with',
]);
const EVENT_FIELDS = Object.freeze({
bash: new Set(['command']),
file: new Set(['file_path', 'new_text', 'old_text', 'content']),
prompt: new Set(['user_prompt']),
stop: new Set(['content']),
all: new Set(['command', 'file_path', 'new_text', 'old_text', 'content', 'user_prompt']),
});
const MODEL_FACING_FORMAT_CONTROL = /[\u200B-\u200F\u2028\u2029\u202A-\u202E\u2066-\u2069\uFEFF]/u;
function diagnostic(code, fileName, detail) {
const label = fileName ? ` ${fileName}` : '';
const suffix = detail ? `: ${detail}` : '.';
return {
code,
message: `Hookify skipped${label}${suffix}`,
};
}
function hasUnsafeControlCharacters(value, allowNewlines = false) {
for (let index = 0; index < value.length; index += 1) {
const code = value.charCodeAt(index);
if (code === 0x7f) return true;
if (code >= 0x20) continue;
if (allowNewlines && (code === 0x09 || code === 0x0a || code === 0x0d)) {
continue;
}
return true;
}
return false;
}
function hasUnsafeModelFacingCharacters(value) {
return MODEL_FACING_FORMAT_CONTROL.test(value);
}
function parseQuotedScalar(rawValue) {
if (rawValue.startsWith('"')) {
if (!rawValue.endsWith('"')) {
throw new Error('unterminated double-quoted value');
}
try {
return JSON.parse(rawValue);
} catch {
throw new Error('invalid double-quoted escape');
}
}
if (rawValue.startsWith("'")) {
if (!rawValue.endsWith("'")) {
throw new Error('unterminated single-quoted value');
}
return rawValue.slice(1, -1).replace(/''/g, "'");
}
return rawValue;
}
function parseScalar(rawValue) {
const value = rawValue.trim();
if (!value) throw new Error('empty scalar');
if (value === 'true') return true;
if (value === 'false') return false;
if (/^[&*!][A-Za-z0-9_-]+(?:\s|$)/.test(value)) {
throw new Error('YAML tags, anchors, and aliases are not supported');
}
return parseQuotedScalar(value);
}
function setUnique(target, key, value) {
if (Object.prototype.hasOwnProperty.call(target, key)) {
throw new Error(`duplicate field ${key}`);
}
target[key] = value;
}
function parseFrontmatter(frontmatterText) {
const result = {};
const lines = frontmatterText.split('\n');
let conditions = null;
let currentCondition = null;
let insideConditions = false;
for (const originalLine of lines) {
const line = originalLine.endsWith('\r')
? originalLine.slice(0, -1)
: originalLine;
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const topLevel = line.match(/^([a-z_]+):(?:[ \t]*(.*))?$/);
if (topLevel) {
const [, key, rawValue = ''] = topLevel;
if (!TOP_LEVEL_FIELDS.has(key)) throw new Error(`unknown field ${key}`);
if (key === 'conditions') {
if (rawValue.trim()) throw new Error('conditions must be a list');
if (conditions !== null) throw new Error('duplicate field conditions');
conditions = [];
result.conditions = conditions;
currentCondition = null;
insideConditions = true;
} else {
setUnique(result, key, parseScalar(rawValue));
currentCondition = null;
insideConditions = false;
}
continue;
}
const listStart = line.match(/^ {2}- ([a-z_]+):(?:[ \t]*(.*))?$/);
if (listStart && insideConditions && conditions) {
const [, key, rawValue = ''] = listStart;
if (!CONDITION_FIELDS.has(key)) throw new Error(`unknown condition field ${key}`);
currentCondition = {};
conditions.push(currentCondition);
setUnique(currentCondition, key, parseScalar(rawValue));
continue;
}
const continuation = line.match(/^ {4}([a-z_]+):(?:[ \t]*(.*))?$/);
if (continuation && insideConditions && currentCondition) {
const [, key, rawValue = ''] = continuation;
if (!CONDITION_FIELDS.has(key)) throw new Error(`unknown condition field ${key}`);
setUnique(currentCondition, key, parseScalar(rawValue));
continue;
}
throw new Error('unsupported YAML structure');
}
return result;
}
function extractDocument(source) {
const normalized = source.replace(/\r\n/g, '\n');
const lines = normalized.split('\n');
if (lines[0] !== '---') throw new Error('missing opening frontmatter delimiter');
const closingIndex = lines.indexOf('---', 1);
if (closingIndex < 0) throw new Error('missing closing frontmatter delimiter');
return {
frontmatter: parseFrontmatter(lines.slice(1, closingIndex).join('\n')),
message: lines.slice(closingIndex + 1).join('\n').trim(),
};
}
function validateString(value, field, options = {}) {
if (typeof value !== 'string' || !value.trim()) {
throw new Error(`${field} must be a non-empty string`);
}
if (hasUnsafeControlCharacters(value, options.allowNewlines === true)) {
throw new Error(`${field} contains control characters`);
}
if (options.modelFacing === true && hasUnsafeModelFacingCharacters(value)) {
throw new Error(`${field} contains unsafe invisible or bidirectional characters`);
}
if (options.maxLength && value.length > options.maxLength) {
throw new Error(`${field} exceeds its length limit`);
}
return value;
}
function validateToolMatcher(value) {
if (value === undefined) return null;
validateString(value, 'tool_matcher', { maxLength: LIMITS.maxToolMatcherLength });
if (value === '*') return value;
const tools = value.split('|');
if (
tools.length === 0 ||
tools.some(tool => tool !== tool.trim() || !TOOL_NAME_PATTERN.test(tool))
) {
throw new Error('tool_matcher must contain exact pipe-separated tool names');
}
return value;
}
function inferredField(event) {
if (event === 'bash') return 'command';
if (event === 'file') return 'file_path';
if (event === 'prompt') return 'user_prompt';
return 'content';
}
function validateCondition(condition, event) {
if (
!condition ||
typeof condition !== 'object' ||
Array.isArray(condition) ||
Object.keys(condition).length !== CONDITION_FIELDS.size ||
![...CONDITION_FIELDS].every(field => Object.prototype.hasOwnProperty.call(condition, field))
) {
throw new Error('each condition requires only field, operator, and pattern');
}
const field = validateString(condition.field, 'condition field');
const operator = validateString(condition.operator, 'condition operator');
const pattern = validateString(condition.pattern, 'condition pattern', {
maxLength: LIMITS.maxPatternLength,
});
if (!EVENT_FIELDS[event].has(field)) {
throw new Error(`condition field ${field} is not valid for event ${event}`);
}
if (!OPERATORS.has(operator)) throw new Error(`unsupported condition operator ${operator}`);
return { field, operator, pattern };
}
function validateRule(frontmatter, message, source) {
const keys = Object.keys(frontmatter);
for (const field of ['name', 'enabled', 'event']) {
if (!keys.includes(field)) throw new Error(`missing required field ${field}`);
}
const name = validateString(frontmatter.name, 'name', { maxLength: 80 });
if (!RULE_NAME_PATTERN.test(name)) {
throw new Error('name must be lower-case kebab-case');
}
if (typeof frontmatter.enabled !== 'boolean') {
throw new Error('enabled must be true or false');
}
const event = validateString(frontmatter.event, 'event');
if (!EVENTS.has(event)) throw new Error(`unsupported event ${event}`);
const action = frontmatter.action === undefined
? 'warn'
: validateString(frontmatter.action, 'action');
if (!ACTIONS.has(action)) throw new Error(`unsupported action ${action}`);
const hasPattern = Object.prototype.hasOwnProperty.call(frontmatter, 'pattern');
const hasConditions = Object.prototype.hasOwnProperty.call(frontmatter, 'conditions');
if (hasPattern === hasConditions) {
throw new Error('define exactly one of pattern or conditions');
}
let conditions;
let pattern = null;
if (hasPattern) {
pattern = validateString(frontmatter.pattern, 'pattern', {
maxLength: LIMITS.maxPatternLength,
});
conditions = [{
field: inferredField(event),
operator: 'regex_match',
pattern,
}];
} else {
if (
!Array.isArray(frontmatter.conditions) ||
frontmatter.conditions.length === 0 ||
frontmatter.conditions.length > LIMITS.maxConditionCount
) {
throw new Error(`conditions must contain 1-${LIMITS.maxConditionCount} items`);
}
conditions = frontmatter.conditions.map(condition => validateCondition(condition, event));
}
validateString(message, 'message', {
allowNewlines: true,
modelFacing: true,
});
if (Buffer.byteLength(message, 'utf8') > LIMITS.maxMessageBytes) {
throw new Error('message exceeds its byte limit');
}
return Object.freeze({
name,
enabled: frontmatter.enabled,
event,
action,
pattern,
conditions: Object.freeze(conditions.map(condition => Object.freeze(condition))),
toolMatcher: validateToolMatcher(frontmatter.tool_matcher),
message,
source,
});
}
function readFileBounded(fileDescriptor, maxBytes) {
const buffer = Buffer.alloc(maxBytes + 1);
let offset = 0;
while (offset < buffer.length) {
const bytesRead = fs.readSync(
fileDescriptor,
buffer,
offset,
buffer.length - offset,
null
);
if (bytesRead === 0) break;
offset += bytesRead;
}
return {
buffer: buffer.subarray(0, Math.min(offset, maxBytes)),
exceeded: offset > maxBytes,
};
}
function isRuleFileReadError(error) {
return ['EACCES', 'EIO', 'EISDIR', 'EPERM'].includes(error?.code);
}
function loadRuleFile({
claudeDir,
fileName,
remainingTotalBytes,
expectedRealDirectory,
}) {
const remainingBytes =
Number.isInteger(remainingTotalBytes) && remainingTotalBytes >= 0
? Math.min(remainingTotalBytes, LIMITS.maxTotalBytes)
: LIMITS.maxTotalBytes;
let consumedBytes = 0;
if (
typeof claudeDir !== 'string' ||
typeof fileName !== 'string' ||
path.basename(fileName) !== fileName ||
!FILE_NAME_PATTERN.test(fileName)
) {
return {
rule: null,
diagnostic: diagnostic(
'HOOKIFY_RULE_FILE_UNSAFE',
FILE_NAME_PATTERN.test(String(fileName || '')) ? fileName : null,
'unsafe rule path rejected'
),
bytesRead: 0,
};
}
const filePath = path.join(claudeDir, fileName);
let fileDescriptor;
try {
const noFollow = fs.constants.O_NOFOLLOW || 0;
try {
fileDescriptor = fs.openSync(filePath, fs.constants.O_RDONLY | noFollow);
} catch (error) {
if (
error &&
['ELOOP', 'EMLINK', 'ENOENT', 'ENOTDIR'].includes(error.code)
) {
return {
rule: null,
diagnostic: diagnostic('HOOKIFY_RULE_FILE_UNSAFE', fileName, 'not a regular file'),
bytesRead: 0,
};
}
throw error;
}
const fileStat = fs.fstatSync(fileDescriptor);
if (!fileStat.isFile()) {
return {
rule: null,
diagnostic: diagnostic('HOOKIFY_RULE_FILE_UNSAFE', fileName, 'not a regular file'),
bytesRead: 0,
};
}
const linkStat = fs.lstatSync(filePath);
if (linkStat.isSymbolicLink() || !linkStat.isFile()) {
return {
rule: null,
diagnostic: diagnostic('HOOKIFY_RULE_FILE_UNSAFE', fileName, 'not a regular file'),
bytesRead: 0,
};
}
if (
fileStat.dev !== linkStat.dev ||
fileStat.ino !== linkStat.ino ||
fileStat.mode !== linkStat.mode ||
fileStat.size !== linkStat.size ||
fileStat.mtimeMs !== linkStat.mtimeMs
) {
return {
rule: null,
diagnostic: diagnostic(
'HOOKIFY_RULE_FILE_UNSAFE',
fileName,
'rule identity changed during evaluation'
),
bytesRead: 0,
};
}
const realDirectory = fs.realpathSync(claudeDir);
if (expectedRealDirectory && realDirectory !== expectedRealDirectory) {
return {
rule: null,
diagnostic: diagnostic(
'HOOKIFY_RULE_FILE_UNSAFE',
fileName,
'project .claude changed during evaluation'
),
bytesRead: 0,
};
}
const realFile = fs.realpathSync(filePath);
if (
path.dirname(realFile) !== realDirectory ||
realFile !== path.join(realDirectory, fileName)
) {
return {
rule: null,
diagnostic: diagnostic(
'HOOKIFY_RULE_FILE_UNSAFE',
fileName,
'resolved outside project .claude'
),
bytesRead: 0,
};
}
if (fileStat.size > LIMITS.maxFileBytes) {
return {
rule: null,
diagnostic: diagnostic('HOOKIFY_RULE_LIMIT', fileName, 'file exceeds byte limit'),
bytesRead: 0,
};
}
if (fileStat.size > remainingBytes) {
return {
rule: null,
diagnostic: diagnostic('HOOKIFY_RULE_LIMIT', fileName, 'total rule byte limit reached'),
bytesRead: 0,
};
}
const read = readFileBounded(
fileDescriptor,
Math.min(LIMITS.maxFileBytes, remainingBytes)
);
consumedBytes = read.buffer.length;
if (read.exceeded) {
return {
rule: null,
diagnostic: diagnostic('HOOKIFY_RULE_LIMIT', fileName, 'rule byte limit reached'),
bytesRead: consumedBytes,
};
}
const source = new TextDecoder('utf-8', { fatal: true }).decode(read.buffer);
const document = extractDocument(source);
return {
rule: validateRule(document.frontmatter, document.message, fileName),
diagnostic: null,
bytesRead: consumedBytes,
};
} catch (error) {
return {
rule: null,
diagnostic: isRuleFileReadError(error)
? diagnostic('HOOKIFY_RULE_READ_FAILED', fileName, 'could not read rule file')
: diagnostic('HOOKIFY_RULE_INVALID', fileName, 'invalid rule schema or encoding'),
bytesRead: consumedBytes,
};
} finally {
if (fileDescriptor !== undefined) {
try {
fs.closeSync(fileDescriptor);
} catch {
// The descriptor is already unusable; the rule still fails open.
}
}
}
}
function listRuleNames(claudeDir) {
const names = [];
let scanned = 0;
let exceededDirectoryLimit = false;
const directory = fs.opendirSync(claudeDir);
try {
let entry;
while ((entry = directory.readSync()) !== null) {
scanned += 1;
if (scanned > LIMITS.maxDirectoryEntries) {
exceededDirectoryLimit = true;
break;
}
if (FILE_NAME_PATTERN.test(entry.name)) names.push(entry.name);
}
} finally {
directory.closeSync();
}
names.sort();
return {
names: names.slice(0, LIMITS.maxRuleFiles),
exceeded: exceededDirectoryLimit || names.length > LIMITS.maxRuleFiles,
};
}
function loadRules(options = {}) {
const projectRoot = path.resolve(options.projectRoot || process.cwd());
const claudeDir = path.join(projectRoot, '.claude');
const diagnostics = [];
const rules = [];
let totalBytes = 0;
let realClaudeDir;
try {
const directoryStat = fs.lstatSync(claudeDir);
if (directoryStat.isSymbolicLink() || !directoryStat.isDirectory()) {
return {
rules,
diagnostics: [
diagnostic(
'HOOKIFY_RULE_DIRECTORY_UNSAFE',
null,
'project .claude must be a real directory'
),
],
totalBytes,
};
}
// Parent project paths can themselves have platform aliases (for example
// `/var` -> `/private/var` on macOS). Compare real paths while still
// requiring `.claude` itself to be the direct child checked by lstat.
const realProjectRoot = fs.realpathSync(projectRoot);
realClaudeDir = fs.realpathSync(claudeDir);
if (realClaudeDir !== path.join(realProjectRoot, '.claude')) {
return {
rules,
diagnostics: [
diagnostic(
'HOOKIFY_RULE_DIRECTORY_UNSAFE',
null,
'project .claude resolved outside the project'
),
],
totalBytes,
};
}
} catch (error) {
if (error && error.code === 'ENOENT') return { rules, diagnostics, totalBytes };
return {
rules,
diagnostics: [
diagnostic(
'HOOKIFY_RULE_DIRECTORY_UNSAFE',
null,
'project .claude could not be inspected'
),
],
totalBytes,
};
}
let listed;
try {
listed = listRuleNames(claudeDir);
} catch {
return {
rules,
diagnostics: [
diagnostic('HOOKIFY_RULE_DIRECTORY_UNSAFE', null, 'project .claude could not be read'),
],
totalBytes,
};
}
if (listed.exceeded) {
diagnostics.push(
diagnostic('HOOKIFY_RULE_LIMIT', null, 'rule or directory entry count limit reached')
);
}
for (const fileName of listed.names) {
const loaded = loadRuleFile({
claudeDir,
fileName,
remainingTotalBytes: LIMITS.maxTotalBytes - totalBytes,
expectedRealDirectory: realClaudeDir,
});
totalBytes += loaded.bytesRead;
if (loaded.diagnostic) {
diagnostics.push(loaded.diagnostic);
continue;
}
const rule = loaded.rule;
if (!rule.enabled) continue;
if (options.event && rule.event !== 'all' && rule.event !== options.event) continue;
if (!options.event && rule.event !== 'all') continue;
rules.push(rule);
}
return { rules, diagnostics, totalBytes };
}
module.exports = {
LIMITS,
extractDocument,
loadRuleFile,
loadRules,
parseFrontmatter,
validateRule,
};