mirror of
https://github.com/affaan-m/ECC.git
synced 2026-10-20 23:11:46 +02:00
validateHookEntry handled command, http and prompt hooks in one function; it now dispatches to validateCommandHook, validateHttpHook and validatePromptHook with the inline node -e check in validateInlineJs. validateHooks reads the file, loads the sidecar and validates the object or legacy array shape through readHooksFile, loadMetadata, validateObjectFormat and validateArrayFormat, so the deepest nesting is gone. Error messages, their order, exit codes and the --update-fingerprints flow are unchanged.
519 lines
15 KiB
JavaScript
519 lines
15 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Validate hooks.json schema and hook entry rules.
|
|
*/
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const vm = require('vm');
|
|
const Ajv = require('ajv');
|
|
|
|
/**
|
|
* Resolve a module by its repo-relative path.
|
|
*
|
|
* Test harnesses copy this validator to the repo root before running it, so a
|
|
* plain relative require would break. Walk up from __dirname until the module
|
|
* is found instead.
|
|
*
|
|
* @param {string} repoRelativePath - e.g. 'scripts/lib/hooks-config.js'
|
|
* @returns {string} absolute path to the module
|
|
*/
|
|
function resolveRepoModule(repoRelativePath) {
|
|
let dir = __dirname;
|
|
for (;;) {
|
|
const candidate = path.join(dir, repoRelativePath);
|
|
if (fs.existsSync(candidate)) {
|
|
return candidate;
|
|
}
|
|
const parent = path.dirname(dir);
|
|
if (parent === dir) {
|
|
throw new Error(`Cannot locate ${repoRelativePath} above ${__dirname}`);
|
|
}
|
|
dir = parent;
|
|
}
|
|
}
|
|
|
|
const {
|
|
METADATA_FILENAME,
|
|
applyHooksMetadata,
|
|
findMetadataMismatches,
|
|
metadataPathFor,
|
|
withRefreshedFingerprints,
|
|
} = require(resolveRepoModule('scripts/lib/hooks-config.js'));
|
|
|
|
const HOOKS_FILE = path.join(__dirname, '../../hooks/hooks.json');
|
|
const HOOKS_SCHEMA_PATH = path.join(__dirname, '../../schemas/hooks.schema.json');
|
|
const METADATA_SCHEMA_PATH = path.join(__dirname, '../../schemas/hooks-metadata.schema.json');
|
|
// `--update-fingerprints` rewrites the sidecar's fingerprints from the current
|
|
// hooks.json instead of validating. Run it after changing a hook command.
|
|
const UPDATE_FINGERPRINTS = process.argv.includes('--update-fingerprints');
|
|
// Keys Claude Code's own hooks schema rejects. Keeping them out of hooks.json is
|
|
// what stops "unknown keys ... ignored" warnings when the plugin loads.
|
|
const HARNESS_UNKNOWN_ROOT_KEYS = ['$schema'];
|
|
const HARNESS_UNKNOWN_MATCHER_KEYS = ['id', 'description'];
|
|
const VALID_EVENTS = [
|
|
'SessionStart',
|
|
'UserPromptSubmit',
|
|
'PreToolUse',
|
|
'PermissionRequest',
|
|
'PostToolUse',
|
|
'PostToolUseFailure',
|
|
'Notification',
|
|
'SubagentStart',
|
|
'Stop',
|
|
'SubagentStop',
|
|
'PreCompact',
|
|
'InstructionsLoaded',
|
|
'TeammateIdle',
|
|
'TaskCompleted',
|
|
'ConfigChange',
|
|
'WorktreeCreate',
|
|
'WorktreeRemove',
|
|
'SessionEnd',
|
|
];
|
|
const VALID_HOOK_TYPES = ['command', 'http', 'prompt', 'agent'];
|
|
const EVENTS_WITHOUT_MATCHER = new Set(['UserPromptSubmit', 'Notification', 'Stop', 'SubagentStop']);
|
|
|
|
function isNonEmptyString(value) {
|
|
return typeof value === 'string' && value.trim().length > 0;
|
|
}
|
|
|
|
function isNonEmptyStringArray(value) {
|
|
return Array.isArray(value) && value.length > 0 && value.every(item => isNonEmptyString(item));
|
|
}
|
|
|
|
function isStringRecord(value) {
|
|
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
&& Object.values(value).every(item => typeof item === 'string');
|
|
}
|
|
|
|
/**
|
|
* Compile the inline program of a `node -e "..."` command so a typo in
|
|
* hooks.json fails validation instead of every hook invocation.
|
|
* @returns {boolean} true if errors were found
|
|
*/
|
|
function validateInlineJs(command, label) {
|
|
const nodeEMatch = command.match(/^node -e "((?:[^"\\]|\\.)*)"(?:\s|$)/s);
|
|
if (!nodeEMatch) return false;
|
|
try {
|
|
new vm.Script(nodeEMatch[1].replace(/\\\\/g, '\\').replace(/\\"/g, '"').replace(/\\n/g, '\n').replace(/\\t/g, '\t'));
|
|
return false;
|
|
} catch (syntaxErr) {
|
|
console.error(`ERROR: ${label} has invalid inline JS: ${syntaxErr.message}`);
|
|
return true;
|
|
}
|
|
}
|
|
|
|
/** @returns {boolean} true if errors were found */
|
|
function validateCommandHook(hook, label) {
|
|
let hasErrors = false;
|
|
|
|
if ('async' in hook && typeof hook.async !== 'boolean') {
|
|
console.error(`ERROR: ${label} 'async' must be a boolean`);
|
|
hasErrors = true;
|
|
}
|
|
|
|
if (!isNonEmptyString(hook.command) && !isNonEmptyStringArray(hook.command)) {
|
|
console.error(`ERROR: ${label} missing or invalid 'command' field`);
|
|
hasErrors = true;
|
|
} else if (typeof hook.command === 'string' && validateInlineJs(hook.command, label)) {
|
|
hasErrors = true;
|
|
}
|
|
|
|
return hasErrors;
|
|
}
|
|
|
|
/** @returns {boolean} true if errors were found */
|
|
function validateHttpHook(hook, label) {
|
|
let hasErrors = false;
|
|
|
|
if (!isNonEmptyString(hook.url)) {
|
|
console.error(`ERROR: ${label} missing or invalid 'url' field`);
|
|
hasErrors = true;
|
|
}
|
|
|
|
if ('headers' in hook && !isStringRecord(hook.headers)) {
|
|
console.error(`ERROR: ${label} 'headers' must be an object with string values`);
|
|
hasErrors = true;
|
|
}
|
|
|
|
if ('allowedEnvVars' in hook && (!Array.isArray(hook.allowedEnvVars) || !hook.allowedEnvVars.every(value => isNonEmptyString(value)))) {
|
|
console.error(`ERROR: ${label} 'allowedEnvVars' must be an array of strings`);
|
|
hasErrors = true;
|
|
}
|
|
|
|
return hasErrors;
|
|
}
|
|
|
|
/** prompt and agent hooks share one shape. @returns {boolean} true if errors were found */
|
|
function validatePromptHook(hook, label) {
|
|
let hasErrors = false;
|
|
|
|
if (!isNonEmptyString(hook.prompt)) {
|
|
console.error(`ERROR: ${label} missing or invalid 'prompt' field`);
|
|
hasErrors = true;
|
|
}
|
|
|
|
if ('model' in hook && !isNonEmptyString(hook.model)) {
|
|
console.error(`ERROR: ${label} 'model' must be a non-empty string`);
|
|
hasErrors = true;
|
|
}
|
|
|
|
return hasErrors;
|
|
}
|
|
|
|
/**
|
|
* Validate a single hook entry has required fields and valid inline JS
|
|
* @param {object} hook - Hook object with type and command fields
|
|
* @param {string} label - Label for error messages (e.g., "PreToolUse[0].hooks[1]")
|
|
* @returns {boolean} true if errors were found
|
|
*/
|
|
function validateHookEntry(hook, label) {
|
|
let hasErrors = false;
|
|
|
|
if (!hook.type || typeof hook.type !== 'string') {
|
|
console.error(`ERROR: ${label} missing or invalid 'type' field`);
|
|
hasErrors = true;
|
|
} else if (!VALID_HOOK_TYPES.includes(hook.type)) {
|
|
console.error(`ERROR: ${label} has unsupported hook type '${hook.type}'`);
|
|
hasErrors = true;
|
|
}
|
|
|
|
if ('timeout' in hook && (typeof hook.timeout !== 'number' || hook.timeout < 0)) {
|
|
console.error(`ERROR: ${label} 'timeout' must be a non-negative number`);
|
|
hasErrors = true;
|
|
}
|
|
|
|
if (hook.type === 'command') {
|
|
return validateCommandHook(hook, label) || hasErrors;
|
|
}
|
|
|
|
if ('async' in hook) {
|
|
console.error(`ERROR: ${label} 'async' is only supported for command hooks`);
|
|
hasErrors = true;
|
|
}
|
|
|
|
if (hook.type === 'http') {
|
|
return validateHttpHook(hook, label) || hasErrors;
|
|
}
|
|
|
|
return validatePromptHook(hook, label) || hasErrors;
|
|
}
|
|
|
|
/**
|
|
* Reject keys the Claude Code harness does not understand.
|
|
*
|
|
* Claude Code validates a plugin's hooks.json against its own schema and prints
|
|
* every unrecognised key at load time. Once a hooks.metadata.json sidecar is
|
|
* present it owns the stable ids and descriptions, so hooks.json must not
|
|
* carry them as well.
|
|
*
|
|
* @param {object} data - Parsed hooks.json.
|
|
* @returns {boolean} true if errors were found
|
|
*/
|
|
function validateHarnessCompatibility(data) {
|
|
if (!data || typeof data !== 'object' || Array.isArray(data)) {
|
|
return false;
|
|
}
|
|
|
|
let hasErrors = false;
|
|
for (const key of HARNESS_UNKNOWN_ROOT_KEYS) {
|
|
if (key in data) {
|
|
console.error(
|
|
`ERROR: hooks.json must not define "${key}" - Claude Code reports it as an unknown key`
|
|
);
|
|
hasErrors = true;
|
|
}
|
|
}
|
|
|
|
const events = data.hooks && typeof data.hooks === 'object' && !Array.isArray(data.hooks)
|
|
? data.hooks
|
|
: {};
|
|
for (const [eventType, matchers] of Object.entries(events)) {
|
|
if (!Array.isArray(matchers)) continue;
|
|
matchers.forEach((matcher, index) => {
|
|
if (!matcher || typeof matcher !== 'object') return;
|
|
for (const key of HARNESS_UNKNOWN_MATCHER_KEYS) {
|
|
if (key in matcher) {
|
|
console.error(
|
|
`ERROR: hooks.json ${eventType}[${index}] must not define "${key}" - `
|
|
+ `move it to ${METADATA_FILENAME}`
|
|
);
|
|
hasErrors = true;
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
return hasErrors;
|
|
}
|
|
|
|
/**
|
|
* Validate a parsed document against a JSON schema file, if the schema exists.
|
|
*
|
|
* @param {object} document - Parsed JSON to validate.
|
|
* @param {string} schemaPath - Path to the schema; skipped when absent.
|
|
* @param {string} label - Name used in error output.
|
|
* @returns {boolean} true if errors were found
|
|
*/
|
|
function validateAgainstSchema(document, schemaPath, label) {
|
|
if (!fs.existsSync(schemaPath)) {
|
|
return false;
|
|
}
|
|
const schema = JSON.parse(fs.readFileSync(schemaPath, 'utf-8'));
|
|
const ajv = new Ajv({ allErrors: true });
|
|
const validate = ajv.compile(schema);
|
|
if (validate(document)) {
|
|
return false;
|
|
}
|
|
for (const err of validate.errors) {
|
|
console.error(`ERROR: ${label} schema: ${err.instancePath || '/'} ${err.message}`);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function isObjectFormat(value) {
|
|
return Boolean(value && typeof value === 'object' && !Array.isArray(value));
|
|
}
|
|
|
|
function isValidMatcherValue(value) {
|
|
return typeof value === 'string' || (typeof value === 'object' && value !== null);
|
|
}
|
|
|
|
function readHooksFile() {
|
|
if (!fs.existsSync(HOOKS_FILE)) {
|
|
console.log('No hooks.json found, skipping validation');
|
|
process.exit(0);
|
|
}
|
|
|
|
let data;
|
|
try {
|
|
data = JSON.parse(fs.readFileSync(HOOKS_FILE, 'utf-8'));
|
|
} catch (e) {
|
|
console.error(`ERROR: Invalid JSON in hooks.json: ${e.message}`);
|
|
process.exit(1);
|
|
}
|
|
return data;
|
|
}
|
|
|
|
/**
|
|
* Load and check the hooks.metadata.json sidecar, if present.
|
|
*
|
|
* Without a sidecar, hooks.json keeps its legacy inline ids. With one, the
|
|
* sidecar is the sole owner of id/description and hooks.json must stay
|
|
* within Claude Code's schema. Exits the process on any sidecar error.
|
|
*
|
|
* @returns {object|null} parsed (and possibly refreshed) metadata, or null
|
|
*/
|
|
function loadMetadata(data, metadataPath) {
|
|
if (!fs.existsSync(metadataPath)) {
|
|
return null;
|
|
}
|
|
|
|
let metadata;
|
|
try {
|
|
metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf-8'));
|
|
} catch (e) {
|
|
console.error(`ERROR: Invalid JSON in ${METADATA_FILENAME}: ${e.message}`);
|
|
process.exit(1);
|
|
}
|
|
|
|
if (validateHarnessCompatibility(data)) {
|
|
process.exit(1);
|
|
}
|
|
|
|
if (UPDATE_FINGERPRINTS) {
|
|
try {
|
|
metadata = withRefreshedFingerprints(data, metadata);
|
|
} catch (error) {
|
|
console.error(`ERROR: ${error.message}`);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
if (validateAgainstSchema(metadata, METADATA_SCHEMA_PATH, METADATA_FILENAME)) {
|
|
process.exit(1);
|
|
}
|
|
|
|
const mismatches = findMetadataMismatches(data, metadata);
|
|
if (mismatches.length > 0) {
|
|
for (const mismatch of mismatches) {
|
|
console.error(`ERROR: ${mismatch}`);
|
|
}
|
|
process.exit(1);
|
|
}
|
|
|
|
return metadata;
|
|
}
|
|
|
|
/**
|
|
* Validate each hook entry of one matcher.
|
|
* @returns {boolean} true if errors were found
|
|
*/
|
|
function validateHookEntries(entries, label) {
|
|
let hasErrors = false;
|
|
for (let j = 0; j < entries.length; j++) {
|
|
if (validateHookEntry(entries[j], `${label}.hooks[${j}]`)) {
|
|
hasErrors = true;
|
|
}
|
|
}
|
|
return hasErrors;
|
|
}
|
|
|
|
/**
|
|
* Validate one matcher of the object format. `ids` tracks stable ids when the
|
|
* document requires them, and is null otherwise.
|
|
* @returns {boolean} true if errors were found
|
|
*/
|
|
function validateObjectMatcher(eventType, index, matcher, ids) {
|
|
const matcherLabel = `${eventType}[${index}]`;
|
|
let hasErrors = false;
|
|
|
|
if (ids && !isNonEmptyString(matcher.id)) {
|
|
console.error(`ERROR: ${matcherLabel} missing or invalid 'id' field`);
|
|
hasErrors = true;
|
|
} else if (ids && ids.has(matcher.id)) {
|
|
console.error(
|
|
`ERROR: ${matcherLabel} has duplicate id '${matcher.id}' (already used by ${ids.get(matcher.id)})`
|
|
);
|
|
hasErrors = true;
|
|
} else if (ids) {
|
|
ids.set(matcher.id, matcherLabel);
|
|
}
|
|
|
|
if (!('matcher' in matcher) && !EVENTS_WITHOUT_MATCHER.has(eventType)) {
|
|
console.error(`ERROR: ${matcherLabel} missing 'matcher' field`);
|
|
hasErrors = true;
|
|
} else if ('matcher' in matcher && !isValidMatcherValue(matcher.matcher)) {
|
|
console.error(`ERROR: ${matcherLabel} has invalid 'matcher' field`);
|
|
hasErrors = true;
|
|
}
|
|
|
|
if (!matcher.hooks || !Array.isArray(matcher.hooks) || matcher.hooks.length === 0) {
|
|
console.error(`ERROR: ${matcherLabel} missing 'hooks' array`);
|
|
hasErrors = true;
|
|
} else if (validateHookEntries(matcher.hooks, matcherLabel)) {
|
|
hasErrors = true;
|
|
}
|
|
|
|
return hasErrors;
|
|
}
|
|
|
|
/**
|
|
* Object format: { EventType: [matchers] }
|
|
* @returns {{ hasErrors: boolean, totalMatchers: number }}
|
|
*/
|
|
function validateObjectFormat(hooks, requiresStableIds) {
|
|
const ids = requiresStableIds ? new Map() : null;
|
|
let hasErrors = false;
|
|
let totalMatchers = 0;
|
|
|
|
for (const [eventType, matchers] of Object.entries(hooks)) {
|
|
if (!VALID_EVENTS.includes(eventType)) {
|
|
console.error(`ERROR: Invalid event type: ${eventType}`);
|
|
hasErrors = true;
|
|
continue;
|
|
}
|
|
|
|
if (!Array.isArray(matchers)) {
|
|
console.error(`ERROR: ${eventType} must be an array`);
|
|
hasErrors = true;
|
|
continue;
|
|
}
|
|
|
|
for (let i = 0; i < matchers.length; i++) {
|
|
const matcher = matchers[i];
|
|
if (typeof matcher !== 'object' || matcher === null) {
|
|
console.error(`ERROR: ${eventType}[${i}] is not an object`);
|
|
hasErrors = true;
|
|
continue;
|
|
}
|
|
if (validateObjectMatcher(eventType, i, matcher, ids)) {
|
|
hasErrors = true;
|
|
}
|
|
totalMatchers++;
|
|
}
|
|
}
|
|
|
|
return { hasErrors, totalMatchers };
|
|
}
|
|
|
|
/** @returns {boolean} true if errors were found */
|
|
function validateLegacyHook(hook, index) {
|
|
let hasErrors = false;
|
|
|
|
if (!('matcher' in hook)) {
|
|
console.error(`ERROR: Hook ${index} missing 'matcher' field`);
|
|
hasErrors = true;
|
|
} else if (!isValidMatcherValue(hook.matcher)) {
|
|
console.error(`ERROR: Hook ${index} has invalid 'matcher' field`);
|
|
hasErrors = true;
|
|
}
|
|
|
|
if (!hook.hooks || !Array.isArray(hook.hooks)) {
|
|
console.error(`ERROR: Hook ${index} missing 'hooks' array`);
|
|
hasErrors = true;
|
|
} else if (validateHookEntries(hook.hooks, `Hook ${index}`)) {
|
|
hasErrors = true;
|
|
}
|
|
|
|
return hasErrors;
|
|
}
|
|
|
|
/**
|
|
* Array format (legacy): [{ matcher, hooks }]
|
|
* @returns {{ hasErrors: boolean, totalMatchers: number }}
|
|
*/
|
|
function validateArrayFormat(hooks) {
|
|
let hasErrors = false;
|
|
for (let i = 0; i < hooks.length; i++) {
|
|
if (validateLegacyHook(hooks[i], i)) {
|
|
hasErrors = true;
|
|
}
|
|
}
|
|
return { hasErrors, totalMatchers: hooks.length };
|
|
}
|
|
|
|
function validateHooks() {
|
|
let data = readHooksFile();
|
|
|
|
const metadataPath = metadataPathFor(HOOKS_FILE);
|
|
const metadata = loadMetadata(data, metadataPath);
|
|
if (metadata) {
|
|
// Validate the merged view so the id/description rules below still apply.
|
|
data = applyHooksMetadata(data, metadata);
|
|
}
|
|
|
|
// Validate against JSON schema
|
|
if (validateAgainstSchema(data, HOOKS_SCHEMA_PATH, 'hooks.json')) {
|
|
process.exit(1);
|
|
}
|
|
|
|
// Support both object format { hooks: {...} } and array format
|
|
const hooks = data.hooks || data;
|
|
const requiresStableIds = isObjectFormat(data) && isObjectFormat(data.hooks);
|
|
|
|
let result;
|
|
if (typeof hooks === 'object' && !Array.isArray(hooks)) {
|
|
result = validateObjectFormat(hooks, requiresStableIds);
|
|
} else if (Array.isArray(hooks)) {
|
|
result = validateArrayFormat(hooks);
|
|
} else {
|
|
console.error('ERROR: hooks.json must be an object or array');
|
|
process.exit(1);
|
|
}
|
|
|
|
if (result.hasErrors) {
|
|
process.exit(1);
|
|
}
|
|
|
|
if (UPDATE_FINGERPRINTS && metadata) {
|
|
fs.writeFileSync(metadataPath, `${JSON.stringify(metadata, null, 2)}\n`);
|
|
console.log(`Updated fingerprints in ${METADATA_FILENAME}`);
|
|
}
|
|
|
|
console.log(`Validated ${result.totalMatchers} hook matchers`);
|
|
}
|
|
|
|
validateHooks();
|