mirror of
https://github.com/affaan-m/ECC.git
synced 2026-10-20 15:01:46 +02:00
validateInlineJs unescaped the shell body with four chained replaces,
so a backslash pair followed by n or t was unescaped twice: the pair
became one backslash, then the new backslash-n became a newline. A
valid hook command such as console.log('\\n') failed validation with
an unterminated string. CodeQL flags the chain as double unescaping.
One regex pass maps each escape exactly once. The regression test
feeds the escaped-backslash program through the validator CLI.
526 lines
16 KiB
JavaScript
526 lines
16 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');
|
|
}
|
|
|
|
const INLINE_ESCAPES = { '\\': '\\', '"': '"', n: '\n', t: '\t' };
|
|
|
|
/** Unescape a shell double-quoted `node -e` body in one pass, so `\\n` stays a literal backslash and n. */
|
|
function unescapeInlineJs(source) {
|
|
return source.replace(/\\(["\\nt])/g, (match, ch) => INLINE_ESCAPES[ch]);
|
|
}
|
|
|
|
/**
|
|
* 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(unescapeInlineJs(nodeEMatch[1]));
|
|
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();
|