Merge reviewed Claude hook registration and settings containment fixes

This commit is contained in:
haelyra
2026-09-07 16:38:41 -04:00
24 changed files with 3602 additions and 340 deletions
+264 -147
View File
@@ -8,8 +8,18 @@ const {
hasExplicitCommitAttributionPreference,
withCommitAttributionDisabled,
} = require('../claude-commit-attribution');
const { writeInstallState } = require('../install-state');
const { readInstallState, writeInstallState } = require('../install-state');
const { assertHookConsentReady, planMaterializesHookRuntime } = require('./hook-consent');
const {
getClaudeSettingsPath,
mergeManagedHooks,
readSettings,
runWithSettingsLock,
uninstallManagedHooks,
updateSettingsAtomic,
validateManagedHooks,
validateRecordedManagedHooks,
} = require('./claude-settings');
const { filterMcpConfig, parseDisabledMcpServers } = require('../mcp-config');
const { assertWithinTrustedRoot } = require('../path-safety');
const {
@@ -200,22 +210,12 @@ function shouldSetClaudeCommitAttributionPreference(plan) {
});
}
function writeClaudeCommitAttributionPreference(settingsPath) {
// Read once rather than probing with existsSync first. Checking for the file and
// then writing it is a file system race (CodeQL js/file-system-race), and a
// missing file is simply the fresh-install case.
function writeClaudeCommitAttributionPreference(settingsPath, options = {}) {
let settings;
try {
settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
} catch (error) {
if (error.code !== 'ENOENT') {
// Unreadable or malformed settings belong to the user; leave them untouched.
return false;
}
settings = {};
}
if (!settings || typeof settings !== 'object' || Array.isArray(settings)) {
settings = readSettings(settingsPath);
} catch (_error) {
// Unreadable or malformed settings belong to the user; leave them untouched.
return false;
}
@@ -223,46 +223,15 @@ function writeClaudeCommitAttributionPreference(settingsPath) {
return false;
}
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
fs.writeFileSync(
settingsPath,
formatJson(withCommitAttributionDisabled(settings)),
'utf8'
);
return true;
}
function replacePluginRootPlaceholders(value, pluginRoot) {
if (!pluginRoot) {
return value;
}
if (typeof value === 'string') {
return value.split('${CLAUDE_PLUGIN_ROOT}').join(pluginRoot);
}
if (Array.isArray(value)) {
return value.map(item => replacePluginRootPlaceholders(item, pluginRoot));
}
if (value && typeof value === 'object') {
return Object.fromEntries(
Object.entries(value).map(([key, nestedValue]) => [
key,
replacePluginRootPlaceholders(nestedValue, pluginRoot),
])
);
}
return value;
}
function findHooksOperation(plan, hooksDestinationPath) {
return plan.operations.find(item => (
item.destinationPath === hooksDestinationPath
&& item.moduleId === 'hooks-runtime'
&& typeof item.sourcePath === 'string'
));
let changed = false;
updateSettingsAtomic(settingsPath, latestSettings => {
if (hasExplicitCommitAttributionPreference(latestSettings)) {
return { settings: latestSettings };
}
changed = true;
return { settings: withCommitAttributionDisabled(latestSettings) };
}, options);
return changed;
}
function isMcpConfigPath(filePath) {
@@ -302,40 +271,135 @@ function assertSafeInstallOperation(plan, operation) {
}
}
function buildResolvedClaudeHooks(plan) {
if (!plan.adapter || (plan.adapter.target !== 'claude' && plan.adapter.target !== 'claude-project')) {
function readPreviousInstallState(plan) {
if (!fs.existsSync(plan.installStatePath)) {
return null;
}
return readInstallState(plan.installStatePath);
}
function comparablePath(filePath) {
const resolved = path.resolve(filePath);
return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
}
function findPreviousManagedHooks(previousState, plan, operation) {
if (
!previousState
|| previousState.target.id !== plan.adapter.id
|| comparablePath(previousState.target.root) !== comparablePath(plan.targetRoot)
|| comparablePath(previousState.target.installStatePath) !== comparablePath(plan.installStatePath)
) {
return null;
}
const pluginRoot = plan.targetRoot;
const hooksDestinationPath = path.join(plan.targetRoot, 'hooks', 'hooks.json');
const hooksOperation = findHooksOperation(plan, hooksDestinationPath);
if (!hooksOperation) {
return null;
}
const hooksSourcePath = hooksOperation.sourcePath;
if (!fs.existsSync(hooksSourcePath)) {
const previousOperation = (previousState.operations || []).find(candidate => (
candidate.kind === operation.kind
&& comparablePath(candidate.destinationPath) === comparablePath(operation.destinationPath)
));
if (!previousOperation || !previousOperation.managedHooks) {
return null;
}
const hooksConfig = readJsonObject(hooksSourcePath, 'hooks config');
const resolvedHooks = replacePluginRootPlaceholders(hooksConfig.hooks, pluginRoot);
if (!resolvedHooks || typeof resolvedHooks !== 'object' || Array.isArray(resolvedHooks)) {
throw new Error(`Invalid hooks config at ${hooksSourcePath}: expected "hooks" to be a JSON object`);
return validateRecordedManagedHooks(
previousOperation.managedHooks,
'previous managed hooks'
);
}
function preflightClaudeSettingsOperations(plan) {
const settingsOperations = plan.operations.filter(operation => (
operation.kind === 'update-claude-settings'
|| operation.kind === 'remove-claude-settings-hooks'
));
if (settingsOperations.length === 0) {
return new Map();
}
const previousState = readPreviousInstallState(plan);
return new Map(settingsOperations.map(operation => {
assertSafeInstallOperation(plan, operation);
const managedHooks = validateManagedHooks(operation.managedHooks);
const settings = readSettings(operation.destinationPath);
const previousManagedHooks = findPreviousManagedHooks(previousState, plan, operation);
if (operation.kind === 'remove-claude-settings-hooks') {
const removal = uninstallManagedHooks(settings, managedHooks);
if (removal.retained.length > 0) {
throw new Error(
`Refusing to disable modified Claude hooks in ${operation.destinationPath}; `
+ 'run the ECC uninstaller to review retained entries.'
);
}
} else {
mergeManagedHooks(settings, managedHooks, { previousManagedHooks });
}
return [operation, { managedHooks, previousManagedHooks }];
}));
}
function prepareHookConsentMigration(plan, migration) {
if (plan.hookConsent !== 'declined') {
return migration;
}
const previousState = readPreviousInstallState(plan);
if (!previousState) {
return migration;
}
const removals = (previousState.operations || [])
.filter(operation => operation.kind === 'update-claude-settings')
.map(operation => ({
...operation,
kind: 'remove-claude-settings-hooks',
strategy: 'remove-hook-ids',
scaffoldOnly: false,
}));
if (removals.length === 0) {
return migration;
}
const removalDestinations = new Set(removals.map(operation => comparablePath(
operation.destinationPath
)));
return {
hooksOperation,
hooksDestinationPath,
resolvedHooksConfig: {
...hooksConfig,
hooks: resolvedHooks,
...migration,
// Disable hooks only after every ordinary install operation succeeds so a
// partial reinstall cannot silently revoke working hooks before failing.
appliedOperations: [...migration.appliedOperations, ...removals],
finalState: {
...migration.finalState,
operations: migration.finalState.operations.filter(operation => !(
operation.kind === 'update-claude-settings'
&& removalDestinations.has(comparablePath(operation.destinationPath))
)),
},
bridgeState: {
...migration.bridgeState,
request: {
...migration.bridgeState.request,
hookConsent: 'enabled',
},
resolution: {
...migration.bridgeState.resolution,
selectedModules: [...new Set([
...migration.bridgeState.resolution.selectedModules,
'hooks-runtime',
])],
},
},
requiresBridgeState: true,
};
}
function previewInstallPlan(plan) {
const migration = prepareClaudeSkillMigration(plan);
const migration = prepareHookConsentMigration(
plan,
prepareClaudeSkillMigration(plan)
);
const appliedPlan = {
...plan,
operations: migration.appliedOperations,
};
preflightClaudeSettingsOperations(appliedPlan);
const hookConsentWarnings = planMaterializesHookRuntime(plan) && plan.hookConsent !== 'enabled'
? ['Applying this plan requires an explicit hook decision: --enable-hooks or --no-hooks.']
: [];
@@ -356,6 +420,23 @@ function previewInstallPlan(plan) {
function applyInstallPlan(plan, dependencies = {}) {
assertHookConsentReady(plan);
const isClaudeManualTarget = plan.adapter
&& (plan.adapter.target === 'claude' || plan.adapter.target === 'claude-project');
const settingsPathToLock = isClaudeManualTarget
? getClaudeSettingsPath(plan.targetRoot)
: null;
if (settingsPathToLock) {
assertSafeInstallOperation(plan, { destinationPath: settingsPathToLock });
}
return settingsPathToLock
? runWithSettingsLock(
settingsPathToLock,
() => applyInstallPlanLocked(plan, dependencies, true)
)
: applyInstallPlanLocked(plan, dependencies, false);
}
function applyInstallPlanLocked(plan, dependencies = {}, settingsLockHeld = false) {
const persistInstallState = dependencies.writeInstallState || writeInstallState;
const beforeInstallStateRead = dependencies.beforeInstallStateRead;
const beforeOperationWrite = dependencies.beforeOperationWrite;
@@ -363,30 +444,36 @@ function applyInstallPlan(plan, dependencies = {}) {
if (typeof beforeInstallStateRead === 'function') {
beforeInstallStateRead({ plan });
}
const migration = prepareClaudeSkillMigration(plan);
const migration = prepareHookConsentMigration(
plan,
prepareClaudeSkillMigration(plan)
);
const appliedPlan = {
...plan,
operations: migration.appliedOperations,
};
const resolvedClaudeHooksPlan = buildResolvedClaudeHooks(appliedPlan);
const preparedClaudeSettings = preflightClaudeSettingsOperations(appliedPlan);
const disabledServers = parseDisabledMcpServers(process.env.ECC_DISABLED_MCPS);
const linkIndex = buildLinkIndexForPlan(appliedPlan);
const hasLegacyMigration = migration.legacyOperationsToRemove.length > 0;
if (migration.requiresBridgeState) {
// Own every operation that may be written during a flat-skill migration
// before the first copy. A later failure is retryable and uninstall can
// clean the entire partial install, including non-skill files. During
// legacy migration the bridge also retains the prior managed operations.
if (typeof beforeInstallStateWrite === 'function') {
beforeInstallStateWrite({ plan: appliedPlan, state: migration.bridgeState });
const hookRemovalCount = appliedPlan.operations.filter(operation => (
operation.kind === 'remove-claude-settings-hooks'
)).length;
let completedHookRemovalCount = 0;
if (migration.requiresBridgeState) {
// Own every operation that may be written during a flat-skill migration
// before the first copy. A later failure is retryable and uninstall can
// clean the entire partial install, including non-skill files. During
// legacy migration the bridge also retains the prior managed operations.
if (typeof beforeInstallStateWrite === 'function') {
beforeInstallStateWrite({ plan: appliedPlan, state: migration.bridgeState });
}
persistInstallState(plan.installStatePath, migration.bridgeState);
}
persistInstallState(plan.installStatePath, migration.bridgeState);
}
let finalState;
try {
for (const operation of appliedPlan.operations) {
let finalState;
try {
for (const operation of appliedPlan.operations) {
assertSafeInstallOperation(appliedPlan, operation);
assertSafeClaudeSkillOperation(appliedPlan, operation);
fs.mkdirSync(path.dirname(operation.destinationPath), { recursive: true });
@@ -399,6 +486,42 @@ function applyInstallPlan(plan, dependencies = {}) {
beforeOperationWrite({ plan: appliedPlan, operation });
}
if (
operation.kind === 'update-claude-settings'
|| operation.kind === 'remove-claude-settings-hooks'
) {
// Re-read at the write boundary so unrelated settings added after
// planning are preserved. A same-ID change still fails closed.
const prepared = preparedClaudeSettings.get(operation);
assertSafeInstallOperation(appliedPlan, operation);
updateSettingsAtomic(operation.destinationPath, latestSettings => {
const merged = operation.kind === 'remove-claude-settings-hooks'
? uninstallManagedHooks(latestSettings, prepared.managedHooks)
: mergeManagedHooks(latestSettings, prepared.managedHooks, {
previousManagedHooks: prepared.previousManagedHooks,
});
if (
operation.kind === 'remove-claude-settings-hooks'
&& merged.retained.length > 0
) {
throw new Error(
`Refusing to disable modified Claude hooks in ${operation.destinationPath}; `
+ 'run the ECC uninstaller to review retained entries.'
);
}
return merged;
}, {
lockHeld: settingsLockHeld,
beforeCommit() {
assertSafeInstallOperation(appliedPlan, operation);
},
});
if (operation.kind === 'remove-claude-settings-hooks') {
completedHookRemovalCount += 1;
}
continue;
}
if (operation.kind === 'merge-json') {
const payload = cloneJsonValue(operation.mergePayload);
if (payload === undefined) {
@@ -450,55 +573,49 @@ function applyInstallPlan(plan, dependencies = {}) {
}
fs.copyFileSync(operation.sourcePath, operation.destinationPath);
}
if (resolvedClaudeHooksPlan) {
assertSafeInstallOperation(appliedPlan, resolvedClaudeHooksPlan.hooksOperation);
fs.mkdirSync(path.dirname(resolvedClaudeHooksPlan.hooksDestinationPath), { recursive: true });
assertSafeInstallOperation(appliedPlan, resolvedClaudeHooksPlan.hooksOperation);
if (typeof beforeOperationWrite === 'function') {
beforeOperationWrite({ plan: appliedPlan, operation: resolvedClaudeHooksPlan.hooksOperation });
}
fs.writeFileSync(
resolvedClaudeHooksPlan.hooksDestinationPath,
JSON.stringify(resolvedClaudeHooksPlan.resolvedHooksConfig, null, 2) + '\n',
'utf8'
);
}
if (hasLegacyMigration) {
removeLegacyClaudeSkillFiles(migration, plan.targetRoot);
}
if (hasLegacyMigration) {
removeLegacyClaudeSkillFiles(migration, plan.targetRoot);
}
if (shouldSetClaudeCommitAttributionPreference(appliedPlan)) {
writeClaudeCommitAttributionPreference(path.join(plan.targetRoot, 'settings.json'));
}
finalState = stateWithContentDigests(migration.finalState, appliedPlan);
if (typeof beforeInstallStateWrite === 'function') {
beforeInstallStateWrite({ plan: appliedPlan, state: finalState });
}
persistInstallState(plan.installStatePath, finalState);
} catch (error) {
if (migration.requiresBridgeState) {
try {
// The bridge was committed before any writes. Refresh it with hashes of
// files that now exist so uninstall can remove only bytes this attempt
// actually installed while preserving user changes.
persistInstallState(
plan.installStatePath,
stateWithContentDigests(migration.bridgeState, appliedPlan)
);
} catch (checkpointError) {
throw new Error(
`${error.message} Install-state checkpoint also failed: ${checkpointError.message}`,
{ cause: error }
if (shouldSetClaudeCommitAttributionPreference(appliedPlan)) {
writeClaudeCommitAttributionPreference(
getClaudeSettingsPath(plan.targetRoot),
{ lockHeld: settingsLockHeld }
);
}
finalState = stateWithContentDigests(migration.finalState, appliedPlan);
if (typeof beforeInstallStateWrite === 'function') {
beforeInstallStateWrite({ plan: appliedPlan, state: finalState });
}
persistInstallState(plan.installStatePath, finalState);
} catch (error) {
if (migration.requiresBridgeState) {
try {
// The bridge was committed before any writes. Refresh it with hashes of
// files that now exist so uninstall can remove only bytes this attempt
// actually installed while preserving user changes.
persistInstallState(
plan.installStatePath,
stateWithContentDigests(
hookRemovalCount > 0 && completedHookRemovalCount === hookRemovalCount
? migration.finalState
: migration.bridgeState,
appliedPlan
)
);
} catch (checkpointError) {
throw new Error(
`${error.message} Install-state checkpoint also failed: ${checkpointError.message}`,
{ cause: error }
);
}
}
throw error;
}
throw error;
}
let antigravityMigrationWarnings = [];
let antigravityMigrationWarnings = [];
try {
const antigravityMigration = cleanupLegacyAntigravityInstall(appliedPlan);
if (antigravityMigration.detected && !antigravityMigration.complete) {
@@ -528,20 +645,20 @@ function applyInstallPlan(plan, dependencies = {}) {
];
}
return {
...plan,
statePreview: finalState,
plannedOperations: [...plan.operations],
operations: migration.appliedOperations,
skippedOperations: migration.skippedOperations,
warnings: [
...(Array.isArray(plan.warnings) ? plan.warnings : []),
...migration.warnings,
...antigravityMigrationWarnings,
...opencodeMigrationWarnings,
],
applied: true,
};
return {
...plan,
statePreview: finalState,
plannedOperations: [...plan.operations],
operations: migration.appliedOperations,
skippedOperations: migration.skippedOperations,
warnings: [
...(Array.isArray(plan.warnings) ? plan.warnings : []),
...migration.warnings,
...antigravityMigrationWarnings,
...opencodeMigrationWarnings,
],
applied: true,
};
}
module.exports = {
+170
View File
@@ -0,0 +1,170 @@
'use strict';
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const INVALID_LOCK_STALE_MS = 5 * 60 * 1000;
function sameFileIdentity(left, right) {
return left.dev === right.dev && left.ino === right.ino;
}
function createSettingsLock(lockPath) {
const tempPath = `${lockPath}.create-${process.pid}-${crypto.randomBytes(8).toString('hex')}`;
let descriptor;
let ownedStats;
try {
descriptor = fs.openSync(tempPath, 'wx', 0o600);
fs.writeFileSync(descriptor, `${JSON.stringify({
pid: process.pid,
startedAt: new Date().toISOString(),
token: crypto.randomBytes(16).toString('hex'),
})}\n`);
fs.fsyncSync(descriptor);
ownedStats = fs.fstatSync(descriptor, { bigint: true });
fs.closeSync(descriptor);
descriptor = undefined;
fs.linkSync(tempPath, lockPath);
} catch (error) {
if (descriptor !== undefined) fs.closeSync(descriptor);
fs.rmSync(tempPath, { force: true });
throw error;
}
fs.rmSync(tempPath, { force: true });
let released = false;
return () => {
if (released) return;
const quarantinePath = `${lockPath}.release-${process.pid}-${crypto.randomBytes(8).toString('hex')}`;
fs.renameSync(lockPath, quarantinePath);
const quarantinedStats = fs.lstatSync(quarantinePath, { bigint: true });
if (!sameFileIdentity(quarantinedStats, ownedStats)) {
if (!fs.existsSync(lockPath)) fs.renameSync(quarantinePath, lockPath);
throw new Error(`Refusing to release a changed Claude settings lock: ${lockPath}`);
}
released = true;
fs.rmSync(quarantinePath, { force: true });
};
}
function inspectSettingsLock(lockPath) {
const descriptor = fs.openSync(lockPath, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0));
try {
const stats = fs.fstatSync(descriptor, { bigint: true });
const pathStats = fs.lstatSync(lockPath, { bigint: true });
if (
!stats.isFile()
|| pathStats.isSymbolicLink()
|| !pathStats.isFile()
|| !sameFileIdentity(stats, pathStats)
) {
return { metadata: null, stats };
}
let metadata = null;
try {
metadata = JSON.parse(fs.readFileSync(descriptor, 'utf8'));
} catch (_error) {
// Invalid locks may be recovered only after the bounded lease below.
}
return { metadata, stats };
} finally {
fs.closeSync(descriptor);
}
}
function processIsAlive(pid) {
try {
process.kill(pid, 0);
return true;
} catch (error) {
return error.code !== 'ESRCH';
}
}
function recoverSettingsLock(lockPath) {
const recoveryPath = `${lockPath}.recover`;
try {
fs.mkdirSync(recoveryPath, { mode: 0o700 });
} catch (error) {
if (error && error.code === 'EEXIST') return null;
throw error;
}
const quarantinePath = `${lockPath}.stale-${process.pid}-${crypto.randomBytes(8).toString('hex')}`;
try {
let inspected;
try {
inspected = inspectSettingsLock(lockPath);
} catch (error) {
if (error && error.code === 'ENOENT') return createSettingsLock(lockPath);
throw error;
}
const validOwner = Number.isSafeInteger(inspected.metadata && inspected.metadata.pid)
&& inspected.metadata.pid > 0;
const stale = validOwner
? !processIsAlive(inspected.metadata.pid)
: Date.now() - Number(inspected.stats.mtimeMs) >= INVALID_LOCK_STALE_MS;
if (!stale) return null;
fs.renameSync(lockPath, quarantinePath);
const quarantinedStats = fs.lstatSync(quarantinePath, { bigint: true });
if (!sameFileIdentity(quarantinedStats, inspected.stats)) {
if (!fs.existsSync(lockPath)) fs.renameSync(quarantinePath, lockPath);
return null;
}
fs.rmSync(quarantinePath, { force: true });
return createSettingsLock(lockPath);
} finally {
fs.rmSync(recoveryPath, { recursive: true, force: true });
fs.rmSync(quarantinePath, { force: true });
}
}
function acquireSettingsLock(settingsPath) {
const lockPath = `${settingsPath}.ecc.lock`;
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
try {
return createSettingsLock(lockPath);
} catch (error) {
if (!error || error.code !== 'EEXIST') {
throw error;
}
}
const recovered = recoverSettingsLock(lockPath);
if (recovered) return recovered;
throw new Error(
`Another ECC process is updating Claude settings: ${settingsPath}. `
+ `If no ECC process is active, inspect and remove ${lockPath}.`
);
}
function runWithSettingsLock(settingsPath, callback) {
const releaseLock = acquireSettingsLock(settingsPath);
let primaryError = null;
let result;
try {
result = callback();
} catch (error) {
primaryError = error;
}
let releaseError = null;
try {
releaseLock();
} catch (error) {
releaseError = error;
}
if (primaryError) {
if (releaseError) primaryError.releaseError = releaseError;
throw primaryError;
}
if (releaseError) throw releaseError;
return result;
}
module.exports = {
acquireSettingsLock,
runWithSettingsLock,
};
+693
View File
@@ -0,0 +1,693 @@
'use strict';
const fs = require('fs');
const path = require('path');
const { isDeepStrictEqual } = require('util');
const { writeFileAtomic } = require('../atomic-write');
const { acquireSettingsLock, runWithSettingsLock } = require('./claude-settings-lock');
const CLAUDE_SETTINGS_FILENAME = 'settings.json';
const CLAUDE_HOOKS_CONFIG_PATH = 'hooks/hooks.json';
const PLUGIN_ROOT_PLACEHOLDER = '${CLAUDE_PLUGIN_ROOT}';
const PLUGIN_ROOT_ENV_PROLOGUE = 'var e=process.env.CLAUDE_PLUGIN_ROOT;';
const PLUGIN_ROOT_ENV_READ = /\bprocess\.env\.CLAUDE_PLUGIN_ROOT\b(?!\s*=)/;
const VALID_EVENTS = new Set([
'SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PermissionRequest',
'PostToolUse', 'PostToolUseFailure', 'Notification', 'SubagentStart',
'Stop', 'SubagentStop', 'PreCompact', 'InstructionsLoaded',
'TeammateIdle', 'TaskCompleted', 'ConfigChange', 'WorktreeCreate',
'WorktreeRemove', 'SessionEnd',
]);
const EVENTS_WITHOUT_MATCHER = new Set([
'UserPromptSubmit', 'Notification', 'Stop', 'SubagentStop',
]);
const VALID_HOOK_TYPES = new Set(['command', 'http', 'prompt', 'agent']);
function isJsonObject(value) {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return false;
}
const prototype = Object.getPrototypeOf(value);
return prototype === Object.prototype || prototype === null;
}
function cloneValue(value) {
if (Array.isArray(value)) {
return value.map(cloneValue);
}
if (isJsonObject(value)) {
return Object.fromEntries(
Object.entries(value).map(([key, nestedValue]) => [key, cloneValue(nestedValue)])
);
}
return value;
}
function isNonEmptyString(value) {
return typeof value === 'string' && value.trim() !== '';
}
function getClaudeSettingsPath(targetRoot) {
return path.join(targetRoot, CLAUDE_SETTINGS_FILENAME);
}
function assertClaudeSettingsPath(destinationPath, trustedRoot) {
const resolvedDestination = path.resolve(destinationPath);
const resolvedExpected = path.resolve(getClaudeSettingsPath(trustedRoot));
const pathsMatch = process.platform === 'win32'
? resolvedDestination.toLowerCase() === resolvedExpected.toLowerCase()
: resolvedDestination === resolvedExpected;
if (!pathsMatch) {
throw new Error(
`Refusing to manage Claude hooks outside the canonical settings file: ${destinationPath}`
);
}
}
function validateHookHandler(hook, label) {
if (!isJsonObject(hook)) {
throw new Error(`Invalid managed hook handler at ${label}: expected a JSON object`);
}
if (!VALID_HOOK_TYPES.has(hook.type)) {
throw new Error(`Invalid managed hook handler at ${label}: unsupported type`);
}
if (hook.timeout !== undefined && (typeof hook.timeout !== 'number' || hook.timeout < 0)) {
throw new Error(`Invalid managed hook handler at ${label}: invalid timeout`);
}
if (hook.type === 'command') {
const validCommand = isNonEmptyString(hook.command)
|| (Array.isArray(hook.command)
&& hook.command.length > 0
&& hook.command.every(isNonEmptyString));
if (!validCommand) {
throw new Error(`Invalid managed hook handler at ${label}: invalid command`);
}
if (hook.async !== undefined && typeof hook.async !== 'boolean') {
throw new Error(`Invalid managed hook handler at ${label}: invalid async flag`);
}
return;
}
if (hook.async !== undefined) {
throw new Error(`Invalid managed hook handler at ${label}: async requires command type`);
}
if (hook.type === 'http') {
if (!isNonEmptyString(hook.url)) {
throw new Error(`Invalid managed hook handler at ${label}: invalid url`);
}
if (
hook.headers !== undefined
&& (!isJsonObject(hook.headers)
|| !Object.values(hook.headers).every(value => typeof value === 'string'))
) {
throw new Error(`Invalid managed hook handler at ${label}: invalid headers`);
}
if (
hook.allowedEnvVars !== undefined
&& (!Array.isArray(hook.allowedEnvVars)
|| !hook.allowedEnvVars.every(isNonEmptyString))
) {
throw new Error(`Invalid managed hook handler at ${label}: invalid allowedEnvVars`);
}
return;
}
if (!isNonEmptyString(hook.prompt)) {
throw new Error(`Invalid managed hook handler at ${label}: invalid prompt`);
}
if (hook.model !== undefined && !isNonEmptyString(hook.model)) {
throw new Error(`Invalid managed hook handler at ${label}: invalid model`);
}
}
function validateManagedHooks(managedHooks, label = 'managed hooks') {
if (!isJsonObject(managedHooks)) {
throw new Error(`Invalid ${label}: expected a JSON object`);
}
if (Object.keys(managedHooks).length === 0) {
throw new Error(`Invalid ${label}: expected at least one hook event`);
}
const seenIds = new Set();
for (const [event, entries] of Object.entries(managedHooks)) {
if (!VALID_EVENTS.has(event)) {
throw new Error(`Invalid ${label}: unsupported hook event "${event}"`);
}
if (!Array.isArray(entries)) {
throw new Error(`Invalid ${label}.${event}: expected an array`);
}
if (entries.length === 0) {
throw new Error(`Invalid ${label}.${event}: expected at least one hook entry`);
}
entries.forEach((entry, index) => {
if (!isJsonObject(entry)) {
throw new Error(
`Invalid managed hook entry at ${label}.${event}[${index}]: expected a JSON object`
);
}
if (typeof entry.id !== 'string' || entry.id.trim() === '') {
throw new Error(
`Invalid managed hook entry at ${label}.${event}[${index}]: `
+ 'expected a non-empty unique id'
);
}
if (seenIds.has(entry.id)) {
throw new Error(`Invalid ${label}: expected globally unique id "${entry.id}"`);
}
seenIds.add(entry.id);
if (
!Object.prototype.hasOwnProperty.call(entry, 'matcher')
&& !EVENTS_WITHOUT_MATCHER.has(event)
) {
throw new Error(
`Invalid managed hook entry at ${label}.${event}[${index}]: missing matcher`
);
}
if (
Object.prototype.hasOwnProperty.call(entry, 'matcher')
&& typeof entry.matcher !== 'string'
&& !isJsonObject(entry.matcher)
) {
throw new Error(
`Invalid managed hook entry at ${label}.${event}[${index}]: invalid matcher`
);
}
if (!Array.isArray(entry.hooks) || entry.hooks.length === 0) {
throw new Error(
`Invalid managed hook entry at ${label}.${event}[${index}]: expected hooks`
);
}
entry.hooks.forEach((hook, hookIndex) => {
validateHookHandler(hook, `${label}.${event}[${index}].hooks[${hookIndex}]`);
});
});
}
return cloneValue(managedHooks);
}
function validateRecordedManagedHooks(managedHooks, label = 'recorded managed hooks') {
if (!isJsonObject(managedHooks) || Object.keys(managedHooks).length === 0) {
throw new Error(`Invalid ${label}: expected a non-empty JSON object`);
}
for (const [event, entries] of Object.entries(managedHooks)) {
if (!isNonEmptyString(event) || !Array.isArray(entries) || entries.length === 0) {
throw new Error(`Invalid ${label}.${event}: expected a non-empty hook array`);
}
const seenIds = new Set();
entries.forEach((entry, index) => {
if (!isJsonObject(entry) || !isNonEmptyString(entry.id) || !Array.isArray(entry.hooks)) {
throw new Error(`Invalid hook entry at ${label}.${event}[${index}]`);
}
if (seenIds.has(entry.id)) {
throw new Error(
`Invalid ${label}: expected unique id "${entry.id}" within event "${event}"`
);
}
seenIds.add(entry.id);
});
}
return cloneValue(managedHooks);
}
function validateSettings(settings, label = 'Claude settings') {
if (!isJsonObject(settings)) {
throw new Error(`Invalid ${label}: expected a JSON object`);
}
if (Object.prototype.hasOwnProperty.call(settings, 'hooks')) {
if (!isJsonObject(settings.hooks)) {
throw new Error(`Invalid ${label}: expected "hooks" to be a JSON object`);
}
for (const [event, entries] of Object.entries(settings.hooks)) {
if (!Array.isArray(entries)) {
throw new Error(`Invalid ${label}: expected hooks.${event} to be an array`);
}
}
}
return cloneValue(settings);
}
function replacePluginRootPlaceholders(value, pluginRoot) {
if (typeof pluginRoot !== 'string') {
throw new Error('Invalid Claude plugin root: expected a string');
}
if (typeof value === 'string') {
return value.split(PLUGIN_ROOT_PLACEHOLDER).join(pluginRoot);
}
if (Array.isArray(value)) {
return value.map(item => replacePluginRootPlaceholders(item, pluginRoot));
}
if (isJsonObject(value)) {
return Object.fromEntries(
Object.entries(value).map(([key, nestedValue]) => [
key,
replacePluginRootPlaceholders(nestedValue, pluginRoot),
])
);
}
return value;
}
function resolveManagedHookCommands(managedHooks, targetRoot) {
const encodedRoot = Buffer.from(targetRoot, 'utf8').toString('base64');
const rootExpression = `Buffer.from('${encodedRoot}','base64').toString('utf8')`;
const resolveCommand = command => {
const resolved = command
.split(PLUGIN_ROOT_ENV_PROLOGUE)
.join(`var e=${rootExpression};`);
if (PLUGIN_ROOT_ENV_READ.test(resolved)) {
throw new Error(
'Unable to resolve CLAUDE_PLUGIN_ROOT in a managed hook command; '
+ 'the hooks.json command prologue no longer matches the expected form'
);
}
return resolved;
};
return Object.fromEntries(
Object.entries(managedHooks).map(([event, entries]) => [
event,
entries.map(entry => ({
...entry,
hooks: entry.hooks.map(hook => ({
...hook,
...(typeof hook.command === 'string'
? {
command: resolveCommand(hook.command),
}
: {}),
})),
})),
])
);
}
function materializeManagedHooks(hooksConfig, targetRoot) {
if (!isJsonObject(hooksConfig) || !isJsonObject(hooksConfig.hooks)) {
throw new Error('Invalid hooks config: expected a JSON object with a hooks object');
}
if (!isNonEmptyString(targetRoot)) {
throw new Error('Invalid Claude target root: expected a non-empty string');
}
return validateManagedHooks(resolveManagedHookCommands(
replacePluginRootPlaceholders(hooksConfig.hooks, targetRoot),
targetRoot
));
}
function parseSettings(rawSettings, label = 'Claude settings') {
let settings;
try {
settings = JSON.parse(rawSettings);
} catch (error) {
throw new Error(`Failed to parse ${label}: ${error.message}`, { cause: error });
}
return validateSettings(settings, label);
}
function readSettings(settingsPath, fileSystem = fs) {
const reader = fileSystem && fileSystem.fs ? fileSystem.fs : fileSystem;
let rawSettings;
try {
rawSettings = reader.readFileSync(settingsPath, 'utf8');
} catch (error) {
if (error && error.code === 'ENOENT') {
return {};
}
throw error;
}
return parseSettings(rawSettings, `Claude settings at ${settingsPath}`);
}
function readSettingsSnapshot(settingsPath) {
const flags = fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0);
let descriptor;
try {
descriptor = fs.openSync(settingsPath, flags);
} catch (error) {
if (error && error.code === 'ENOENT') {
return { exists: false, raw: null, settings: {}, mode: 0o600 };
}
throw error;
}
try {
const descriptorStat = fs.fstatSync(descriptor);
let pathStat;
try {
pathStat = fs.lstatSync(settingsPath);
} catch (error) {
if (error && error.code === 'ENOENT') {
error.code = 'ECC_SETTINGS_CHANGED';
}
throw error;
}
if (
!descriptorStat.isFile()
|| !pathStat.isFile()
|| pathStat.isSymbolicLink()
|| descriptorStat.dev !== pathStat.dev
|| descriptorStat.ino !== pathStat.ino
) {
const error = new Error(`Refusing to read changed Claude settings at ${settingsPath}`);
error.code = 'ECC_SETTINGS_CHANGED';
throw error;
}
const raw = fs.readFileSync(descriptor, 'utf8');
return {
exists: true,
raw,
settings: parseSettings(raw, `Claude settings at ${settingsPath}`),
mode: descriptorStat.mode & 0o777,
dev: descriptorStat.dev,
ino: descriptorStat.ino,
};
} finally {
fs.closeSync(descriptor);
}
}
function assertSettingsSnapshotUnchanged(settingsPath, snapshot) {
let current;
try {
current = readSettingsSnapshot(settingsPath);
} catch (error) {
error.code = error.code || 'ECC_SETTINGS_CHANGED';
throw error;
}
const unchanged = current.exists === snapshot.exists
&& current.raw === snapshot.raw
&& (!current.exists || (current.dev === snapshot.dev && current.ino === snapshot.ino));
if (!unchanged) {
const error = new Error(`Claude settings changed during update: ${settingsPath}`);
error.code = 'ECC_SETTINGS_CHANGED';
throw error;
}
}
function updateSettingsAtomic(settingsPath, transform, options = {}) {
const update = () => {
const parentPath = path.dirname(path.resolve(settingsPath));
const parentStats = fs.lstatSync(parentPath, { bigint: true });
const validateParent = () => {
const current = fs.lstatSync(parentPath, { bigint: true });
if (
!current.isDirectory() || current.isSymbolicLink()
|| current.dev !== parentStats.dev || current.ino !== parentStats.ino
) {
const error = new Error(`Claude settings parent directory changed: ${parentPath}`);
error.code = 'ECC_SETTINGS_PARENT_CHANGED';
throw error;
}
};
const maxAttempts = options.maxAttempts || 3;
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
try {
validateParent();
const snapshot = readSettingsSnapshot(settingsPath);
const result = transform(snapshot.settings);
if (typeof options.beforeCommit === 'function') options.beforeCommit();
assertSettingsSnapshotUnchanged(settingsPath, snapshot);
writeFileAtomic(
settingsPath,
`${JSON.stringify(result.settings, null, 2)}\n`,
{
encoding: 'utf8',
mode: snapshot.mode,
validateParent,
beforeRename() {
assertSettingsSnapshotUnchanged(settingsPath, snapshot);
},
}
);
return result;
} catch (error) {
if (error.code !== 'ECC_SETTINGS_CHANGED' || attempt === maxAttempts) {
throw error;
}
}
}
throw new Error(`Unable to update Claude settings at ${settingsPath}`);
};
if (options.lockHeld) {
return update();
}
return runWithSettingsLock(settingsPath, update);
}
function reference(event, id) {
return { event, id };
}
function entriesMatchingId(entries, id) {
return entries
.map((entry, index) => ({ entry, index }))
.filter(candidate => isJsonObject(candidate.entry) && candidate.entry.id === id);
}
function assertUnambiguousMatch(entries, event, id) {
const matches = entriesMatchingId(entries, id);
if (matches.length > 1) {
throw new Error(
`Claude settings contains multiple hooks for event "${event}" and id "${id}"`
);
}
return matches[0] || null;
}
function managedEntryFor(managedHooks, event, id) {
const entries = managedHooks && managedHooks[event];
if (!Array.isArray(entries)) {
return null;
}
return entries.find(entry => entry.id === id) || null;
}
function mergeManagedHooks(settings, managedHooks, options = {}) {
const validatedSettings = validateSettings(settings);
const desiredHooks = validateManagedHooks(managedHooks);
const previousHooks = options.previousManagedHooks === undefined
|| options.previousManagedHooks === null
? null
: validateRecordedManagedHooks(options.previousManagedHooks, 'previous managed hooks');
const repair = options.mode === 'repair' || options.repair === true;
if (options.mode !== undefined && options.mode !== 'merge' && options.mode !== 'repair') {
throw new Error(`Unknown Claude settings merge mode: ${options.mode}`);
}
let nextHooks = validatedSettings.hooks
? cloneValue(validatedSettings.hooks)
: {};
const added = [];
const updated = [];
const unchanged = [];
const removed = [];
if (previousHooks) {
for (const [event, previousEntries] of Object.entries(previousHooks)) {
let eventEntries = nextHooks[event] ? cloneValue(nextHooks[event]) : [];
for (const previousEntry of previousEntries) {
if (managedEntryFor(desiredHooks, event, previousEntry.id)) continue;
const match = assertUnambiguousMatch(eventEntries, event, previousEntry.id);
if (!match) continue;
if (!isDeepStrictEqual(match.entry, previousEntry)) {
throw new Error(
`Refusing to remove Claude hook for event "${event}" and id `
+ `"${previousEntry.id}" because the previous managed entry has drifted`
);
}
eventEntries = eventEntries.filter((_entry, index) => index !== match.index);
removed.push(reference(event, previousEntry.id));
}
nextHooks = eventEntries.length > 0
? { ...nextHooks, [event]: eventEntries }
: withoutProperty(nextHooks, event);
}
}
for (const [event, desiredEntries] of Object.entries(desiredHooks)) {
let eventEntries = nextHooks[event] ? cloneValue(nextHooks[event]) : [];
for (const desiredEntry of desiredEntries) {
const match = assertUnambiguousMatch(eventEntries, event, desiredEntry.id);
if (!match) {
eventEntries = [...eventEntries, cloneValue(desiredEntry)];
added.push(reference(event, desiredEntry.id));
continue;
}
if (isDeepStrictEqual(match.entry, desiredEntry)) {
unchanged.push(reference(event, desiredEntry.id));
continue;
}
const previousEntry = managedEntryFor(previousHooks, event, desiredEntry.id);
if (!repair && (!previousEntry || !isDeepStrictEqual(match.entry, previousEntry))) {
const driftReason = previousEntry ? ' because the previous managed entry has drifted' : '';
throw new Error(
`Refusing to overwrite Claude hook for event "${event}" and id `
+ `"${desiredEntry.id}"${driftReason}`
);
}
eventEntries = eventEntries.map((entry, index) => (
index === match.index ? cloneValue(desiredEntry) : entry
));
updated.push(reference(event, desiredEntry.id));
}
if (desiredEntries.length > 0) {
nextHooks = { ...nextHooks, [event]: eventEntries };
}
}
const nextSettings = Object.keys(nextHooks).length > 0
? { ...validatedSettings, hooks: nextHooks }
: validatedSettings;
return {
settings: nextSettings,
managedHooks: cloneValue(desiredHooks),
added,
updated,
unchanged,
removed,
};
}
function repairManagedHooks(settings, managedHooks, options = {}) {
return mergeManagedHooks(settings, managedHooks, {
...options,
mode: 'repair',
});
}
function inspectManagedHooks(settings, managedHooks) {
const validatedSettings = validateSettings(settings);
const expectedHooks = validateManagedHooks(managedHooks);
const settingsHooks = validatedSettings.hooks || {};
const managedSubset = {};
const matched = [];
const missing = [];
const drifted = [];
for (const [event, expectedEntries] of Object.entries(expectedHooks)) {
const actualEntries = settingsHooks[event] || [];
const foundEntries = [];
for (const expectedEntry of expectedEntries) {
const match = assertUnambiguousMatch(actualEntries, event, expectedEntry.id);
if (!match) {
missing.push(reference(event, expectedEntry.id));
continue;
}
foundEntries.push(cloneValue(match.entry));
if (isDeepStrictEqual(match.entry, expectedEntry)) {
matched.push(reference(event, expectedEntry.id));
} else {
drifted.push({
...reference(event, expectedEntry.id),
expected: cloneValue(expectedEntry),
actual: cloneValue(match.entry),
});
}
}
if (foundEntries.length > 0) {
managedSubset[event] = foundEntries;
}
}
const ok = missing.length === 0 && drifted.length === 0;
return {
status: ok ? 'ok' : (missing.length > 0 ? 'missing' : 'drifted'),
ok,
managedHooks: managedSubset,
matched,
missing,
drifted,
};
}
function withoutProperty(object, omittedKey) {
return Object.fromEntries(
Object.entries(object).filter(([key]) => key !== omittedKey)
);
}
function uninstallManagedHooks(settings, recordedManagedHooks) {
const validatedSettings = validateSettings(settings);
const recordedHooks = validateRecordedManagedHooks(recordedManagedHooks);
const currentHooks = validatedSettings.hooks || {};
for (const [event, recordedEntries] of Object.entries(recordedHooks)) {
const eventEntries = currentHooks[event] || [];
for (const recordedEntry of recordedEntries) {
assertUnambiguousMatch(eventEntries, event, recordedEntry.id);
}
}
const removed = [];
const retained = [];
const missing = [];
let nextHooks = cloneValue(currentHooks);
for (const [event, recordedEntries] of Object.entries(recordedHooks)) {
let eventEntries = nextHooks[event] || [];
for (const recordedEntry of recordedEntries) {
const match = assertUnambiguousMatch(eventEntries, event, recordedEntry.id);
if (!match) {
missing.push(reference(event, recordedEntry.id));
continue;
}
if (!isDeepStrictEqual(match.entry, recordedEntry)) {
retained.push({
...reference(event, recordedEntry.id),
expected: cloneValue(recordedEntry),
actual: cloneValue(match.entry),
reason: 'modified',
});
continue;
}
eventEntries = eventEntries.filter((_entry, index) => index !== match.index);
removed.push(reference(event, recordedEntry.id));
}
nextHooks = eventEntries.length > 0
? { ...nextHooks, [event]: eventEntries }
: withoutProperty(nextHooks, event);
}
nextHooks = Object.fromEntries(
Object.entries(nextHooks).filter(([, entries]) => entries.length > 0)
);
const settingsWithoutHooks = withoutProperty(validatedSettings, 'hooks');
const nextSettings = Object.keys(nextHooks).length > 0
? { ...settingsWithoutHooks, hooks: nextHooks }
: settingsWithoutHooks;
return {
settings: nextSettings,
removed,
retained,
missing,
};
}
module.exports = {
CLAUDE_HOOKS_CONFIG_PATH,
CLAUDE_SETTINGS_FILENAME,
acquireSettingsLock,
assertClaudeSettingsPath,
getClaudeSettingsPath,
inspectManagedHooks,
materializeManagedHooks,
mergeManagedHooks,
parseSettings,
readSettings,
repairManagedHooks,
replacePluginRootPlaceholders,
runWithSettingsLock,
updateSettingsAtomic,
uninstallManagedHooks,
validateManagedHooks,
validateRecordedManagedHooks,
validateSettings,
};
+4 -1
View File
@@ -44,7 +44,10 @@ function normalizeOperationPath(value) {
}
function isHookRuntimeOperation(operation = {}) {
if (operation.moduleId === HOOK_RUNTIME_MODULE_ID) {
if (
operation.kind === 'update-claude-settings'
|| operation.moduleId === HOOK_RUNTIME_MODULE_ID
) {
return true;
}
+27
View File
@@ -7,6 +7,9 @@ const { execFileSync } = require('child_process');
const { resolveInstallPlan } = require('../install-manifests');
const { getInstallTargetAdapter } = require('../install-targets/registry');
const { resolveInvocationEnvironment } = require('../invocation-environment');
const {
materializeManagedHooks,
} = require('./claude-settings');
const EXCLUDED_GENERATED_SOURCE_SUFFIXES = ['/ecc-install-state.json', '/ecc/install-state.json'];
const IGNORED_DIRECTORY_NAMES = new Set([
@@ -127,7 +130,31 @@ function readJsonObject(filePath, label) {
return parsed;
}
function materializeClaudeSettingsOperation(sourceRoot, operation) {
const sourcePath = path.join(sourceRoot, operation.sourceRelativePath);
if (!fs.existsSync(sourcePath)) {
return [];
}
const hooksConfig = readJsonObject(sourcePath, operation.sourceRelativePath);
const managedHooks = materializeManagedHooks(
hooksConfig,
path.dirname(operation.destinationPath)
);
return [{
...operation,
sourcePath,
scaffoldOnly: false,
managedHooks,
}];
}
function materializeScaffoldOperation(sourceRoot, operation) {
if (operation.kind === 'update-claude-settings') {
return materializeClaudeSettingsOperation(sourceRoot, operation);
}
if (operation.kind === 'merge-json') {
return [
{