fix: validate hook field requirements to prevent silent settings.json rejection

Add validateHookFields() that strips invalid hook entries before they
cause Claude Code's Zod schema to silently discard the entire
settings.json file. Agent hooks require "prompt", command hooks require
"command", and entries without a valid hooks sub-array are removed.

Uses a clean two-pass approach: first validate and build new arrays
(no mutation inside filter predicates), then collect-and-delete empty
event keys (no delete during Object.keys iteration). Result entries
are shallow copies so the original input objects are never mutated.

Includes 24 tests covering passthrough, removal, structural invalidity,
empty cleanup, mutation safety, unknown types, and iteration safety.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-03-23 22:11:54 -04:00
parent ae69e6e9e4
commit c2292598c7
2 changed files with 456 additions and 1 deletions

View File

@@ -2910,6 +2910,85 @@ function cleanupOrphanedHooks(settings) {
return settings;
}
/**
* Validate hook field requirements to prevent silent settings.json rejection.
*
* Claude Code validates the entire settings file with a strict Zod schema.
* If ANY hook has an invalid schema (e.g., type: "agent" missing "prompt"),
* the ENTIRE settings.json is silently discarded — disabling all plugins,
* env vars, and other configuration.
*
* This defensive check removes invalid hook entries and cleans up empty
* event arrays to prevent this. It validates:
* - agent hooks require a "prompt" field
* - command hooks require a "command" field
* - entries must have a valid "hooks" array (non-array/missing is removed)
*
* @param {object} settings - The settings object (mutated in place)
* @returns {object} The same settings object
*/
function validateHookFields(settings) {
if (!settings.hooks || typeof settings.hooks !== 'object') return settings;
let fixedHooks = false;
const emptyKeys = [];
for (const [eventType, hookEntries] of Object.entries(settings.hooks)) {
if (!Array.isArray(hookEntries)) continue;
// Pass 1: validate each entry, building a new array without mutation
const validated = [];
for (const entry of hookEntries) {
// Entries without a hooks sub-array are structurally invalid — remove them
if (!entry.hooks || !Array.isArray(entry.hooks)) {
fixedHooks = true;
continue;
}
// Filter invalid hooks within the entry
const validHooks = entry.hooks.filter(h => {
if (h.type === 'agent' && !h.prompt) {
fixedHooks = true;
return false;
}
if (h.type === 'command' && !h.command) {
fixedHooks = true;
return false;
}
return true;
});
// Drop entries whose hooks are now empty
if (validHooks.length === 0) {
fixedHooks = true;
continue;
}
// Build a clean copy instead of mutating the original entry
validated.push({ ...entry, hooks: validHooks });
}
settings.hooks[eventType] = validated;
// Collect empty event arrays for removal (avoid delete during iteration)
if (validated.length === 0) {
emptyKeys.push(eventType);
fixedHooks = true;
}
}
// Pass 2: remove empty event arrays
for (const key of emptyKeys) {
delete settings.hooks[key];
}
if (fixedHooks) {
console.log(` ${green}✓${reset} Fixed invalid hook entries (prevents settings.json schema rejection)`);
}
return settings;
}
/**
* Uninstall GSD from the specified directory for a specific runtime
* Removes only GSD-specific files/directories, preserves user content
@@ -4027,7 +4106,7 @@ function install(isGlobal, runtime = 'claude') {
// Gemini and Antigravity use AfterTool instead of PostToolUse for post-tool hooks
const postToolEvent = (runtime === 'gemini' || runtime === 'antigravity') ? 'AfterTool' : 'PostToolUse';
const settingsPath = path.join(targetDir, 'settings.json');
const settings = cleanupOrphanedHooks(readSettings(settingsPath));
const settings = validateHookFields(cleanupOrphanedHooks(readSettings(settingsPath)));
const statuslineCommand = isGlobal
? buildHookCommand(targetDir, 'gsd-statusline.js')
: 'node ' + dirName + '/hooks/gsd-statusline.js';
@@ -4455,6 +4534,7 @@ if (process.env.GSD_TEST_MODE) {
copyCommandsAsAntigravitySkills,
writeManifest,
reportLocalPatches,
validateHookFields,
};
} else {