mirror of
https://github.com/affaan-m/ECC.git
synced 2026-09-17 23:28:04 +02:00
fix(install): harden Claude settings lifecycle
This commit is contained in:
@@ -207,7 +207,7 @@ function validateHooks() {
|
||||
console.error(`ERROR: ${matcherLabel} has invalid 'matcher' field`);
|
||||
hasErrors = true;
|
||||
}
|
||||
if (!matcher.hooks || !Array.isArray(matcher.hooks)) {
|
||||
if (!matcher.hooks || !Array.isArray(matcher.hooks) || matcher.hooks.length === 0) {
|
||||
console.error(`ERROR: ${matcherLabel} missing 'hooks' array`);
|
||||
hasErrors = true;
|
||||
} else {
|
||||
|
||||
@@ -3,6 +3,7 @@ const fs = require('fs');
|
||||
const { execFileSync } = require('child_process');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { isDeepStrictEqual } = require('util');
|
||||
|
||||
const { loadInstallManifests } = require('./install-manifests');
|
||||
const { readInstallState, validateInstallState } = require('./install-state');
|
||||
@@ -22,6 +23,8 @@ const {
|
||||
} = require('./install/opencode-legacy-migration');
|
||||
const {
|
||||
acquireSettingsLock,
|
||||
assertClaudeSettingsPath,
|
||||
getClaudeSettingsPath,
|
||||
inspectManagedHooks,
|
||||
materializeManagedHooks,
|
||||
repairManagedHooks,
|
||||
@@ -532,22 +535,11 @@ function readJsonNoFollow(filePath) {
|
||||
return JSON.parse(readFileNoFollow(filePath, 'utf8'));
|
||||
}
|
||||
|
||||
function expectedClaudeSettingsPath(targetRoot) {
|
||||
return path.join(targetRoot, 'settings.json');
|
||||
}
|
||||
|
||||
function assertClaudeSettingsDestination(operation, trustedRoot, target = null) {
|
||||
if (target && target !== 'claude' && target !== 'claude-project') {
|
||||
throw new Error('Refusing to manage Claude hooks for a non-Claude target.');
|
||||
}
|
||||
if (path.resolve(operation.destinationPath) !== path.resolve(
|
||||
expectedClaudeSettingsPath(trustedRoot)
|
||||
)) {
|
||||
throw new Error(
|
||||
`Refusing to manage Claude hooks outside the canonical settings file: `
|
||||
+ `${operation.destinationPath}`
|
||||
);
|
||||
}
|
||||
assertClaudeSettingsPath(operation.destinationPath, trustedRoot);
|
||||
}
|
||||
|
||||
function writeContainedFile(destinationPath, content, trustedRoot, action, mode) {
|
||||
@@ -714,7 +706,7 @@ function deepRemoveJsonSubset(currentValue, managedValue) {
|
||||
return currentValue === managedValue ? JSON_REMOVE_SENTINEL : currentValue;
|
||||
}
|
||||
|
||||
function hydrateRecordedOperations(repoRoot, operations) {
|
||||
function hydrateRecordedOperations(repoRoot, operations, trustedRoot) {
|
||||
return operations.map(operation => {
|
||||
if (operation.kind === 'update-claude-settings') {
|
||||
const sourcePath = resolveOperationSourcePath(repoRoot, operation);
|
||||
@@ -729,7 +721,7 @@ function hydrateRecordedOperations(repoRoot, operations) {
|
||||
previousManagedHooks: operation.managedHooks,
|
||||
managedHooks: materializeManagedHooks(
|
||||
readJsonNoFollow(sourcePath),
|
||||
path.dirname(operation.destinationPath)
|
||||
trustedRoot
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -1357,12 +1349,6 @@ function summarizeManagedOperationHealth(repoRoot, trustedRoot, operations, targ
|
||||
);
|
||||
}
|
||||
|
||||
function hookRepairOperations(operationHealth) {
|
||||
return operationHealth.drifted
|
||||
.filter(entry => entry.operation.kind === 'update-claude-settings')
|
||||
.map(entry => ({ ...entry.operation }));
|
||||
}
|
||||
|
||||
function getUnsafeManagedDestinationError(operationHealth) {
|
||||
const hasFinalSymlink = operationHealth.unsafeDestination.some(
|
||||
inspection => inspection.reason === 'final-symlink'
|
||||
@@ -1806,7 +1792,11 @@ function createRepairPlanFromRecord(record, context, options = {}) {
|
||||
record.legacyLayout !== 'opencode'
|
||||
&& (state.request.legacyMode || shouldRepairFromRecordedOperations(state))
|
||||
) {
|
||||
const operations = hydrateRecordedOperations(context.repoRoot, getManagedOperations(state));
|
||||
const operations = hydrateRecordedOperations(
|
||||
context.repoRoot,
|
||||
getManagedOperations(state),
|
||||
record.targetRoot
|
||||
);
|
||||
const statePreview = buildRecordedStatePreview(state, context, operations);
|
||||
|
||||
return {
|
||||
@@ -1951,15 +1941,14 @@ function repairInstalledStates(options = {}) {
|
||||
|
||||
let releaseSettingsLock = null;
|
||||
try {
|
||||
if (
|
||||
!options.dryRun
|
||||
const settingsPathToLock = !options.dryRun
|
||||
&& getManagedOperations(record.state || {}).some(
|
||||
operation => operation.kind === 'update-claude-settings'
|
||||
)
|
||||
) {
|
||||
releaseSettingsLock = acquireSettingsLock(
|
||||
path.join(record.targetRoot, 'settings.json')
|
||||
);
|
||||
? getClaudeSettingsPath(record.targetRoot)
|
||||
: null;
|
||||
if (settingsPathToLock) {
|
||||
releaseSettingsLock = acquireSettingsLock(settingsPathToLock);
|
||||
}
|
||||
const needsOpencodeBuild = record.adapter.target === 'opencode'
|
||||
&& hasOpencodeBuildError(getOpencodeBuildValidationIssues(context));
|
||||
@@ -2107,16 +2096,13 @@ function repairInstalledStates(options = {}) {
|
||||
const repairOperations = [
|
||||
...operationHealth.missing.map(entry => ({ ...entry.operation })),
|
||||
...operationHealth.drifted.map(entry => ({ ...entry.operation })),
|
||||
...hookRepairOperations({
|
||||
drifted: desiredPlan.operations
|
||||
.filter(operation => (
|
||||
operation.kind === 'update-claude-settings'
|
||||
&& operation.previousManagedHooks
|
||||
&& JSON.stringify(operation.previousManagedHooks)
|
||||
!== JSON.stringify(operation.managedHooks)
|
||||
))
|
||||
.map(operation => ({ operation })),
|
||||
}),
|
||||
...desiredPlan.operations
|
||||
.filter(operation => (
|
||||
operation.kind === 'update-claude-settings'
|
||||
&& operation.previousManagedHooks
|
||||
&& !isDeepStrictEqual(operation.previousManagedHooks, operation.managedHooks)
|
||||
))
|
||||
.map(operation => ({ ...operation })),
|
||||
].filter((operation, index, items) => items.findIndex(candidate => (
|
||||
candidate.kind === operation.kind
|
||||
&& candidate.destinationPath === operation.destinationPath
|
||||
@@ -2333,7 +2319,7 @@ function uninstallInstalledStates(options = {}) {
|
||||
const operations = getManagedOperations(state);
|
||||
if (operations.some(operation => operation.kind === 'update-claude-settings')) {
|
||||
releaseSettingsLock = acquireSettingsLock(
|
||||
path.join(record.targetRoot, 'settings.json')
|
||||
getClaudeSettingsPath(record.targetRoot)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { validateManagedHooks } = require('./install/claude-settings');
|
||||
const {
|
||||
CLAUDE_HOOKS_CONFIG_PATH,
|
||||
getClaudeSettingsPath,
|
||||
validateRecordedManagedHooks,
|
||||
} = require('./install/claude-settings');
|
||||
|
||||
// Dependency-free, self-contained validation. The installer closure must not
|
||||
// require any non-builtin package (enterprise supply-chain vetting: the vetted
|
||||
@@ -217,14 +221,14 @@ function createFallbackValidator() {
|
||||
if (operation.moduleId !== 'hooks-runtime') {
|
||||
pushError(`${instancePath}/moduleId`, 'must equal hooks-runtime');
|
||||
}
|
||||
if (String(operation.sourceRelativePath).replace(/\\/g, '/') !== 'hooks/hooks.json') {
|
||||
if (String(operation.sourceRelativePath).replace(/\\/g, '/') !== CLAUDE_HOOKS_CONFIG_PATH) {
|
||||
pushError(`${instancePath}/sourceRelativePath`, 'must equal hooks/hooks.json');
|
||||
}
|
||||
if (
|
||||
isNonEmptyString(state.target && state.target.root)
|
||||
&& isNonEmptyString(operation.destinationPath)
|
||||
) {
|
||||
const expectedDestination = path.resolve(state.target.root, 'settings.json');
|
||||
const expectedDestination = path.resolve(getClaudeSettingsPath(state.target.root));
|
||||
const actualDestination = path.resolve(operation.destinationPath);
|
||||
const pathsMatch = process.platform === 'win32'
|
||||
? expectedDestination.toLowerCase() === actualDestination.toLowerCase()
|
||||
@@ -237,7 +241,7 @@ function createFallbackValidator() {
|
||||
}
|
||||
}
|
||||
try {
|
||||
validateManagedHooks(operation.managedHooks);
|
||||
validateRecordedManagedHooks(operation.managedHooks);
|
||||
} catch (error) {
|
||||
pushError(`${instancePath}/managedHooks`, error.message);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const {
|
||||
@@ -6,42 +5,10 @@ const {
|
||||
createRemappedOperation,
|
||||
isForeignPlatformPath,
|
||||
normalizeRelativePath,
|
||||
planClaudeHooksOperations,
|
||||
} = require('./helpers');
|
||||
|
||||
const CLAUDE_ECC_NAMESPACE = 'ecc';
|
||||
const CLAUDE_HOOKS_CONFIG_PATH = 'hooks/hooks.json';
|
||||
|
||||
function planClaudeHooksOperations(adapter, module, input) {
|
||||
const sourceHooksRoot = path.join(input.repoRoot || '', 'hooks');
|
||||
const operations = [
|
||||
createRemappedOperation(
|
||||
adapter,
|
||||
module.id,
|
||||
CLAUDE_HOOKS_CONFIG_PATH,
|
||||
path.join(adapter.resolveRoot(input), 'settings.json'),
|
||||
{
|
||||
kind: 'update-claude-settings',
|
||||
strategy: 'merge-hook-ids',
|
||||
}
|
||||
),
|
||||
];
|
||||
|
||||
if (!input.repoRoot || !fs.existsSync(sourceHooksRoot)) {
|
||||
return operations;
|
||||
}
|
||||
|
||||
return [
|
||||
...operations,
|
||||
...fs.readdirSync(sourceHooksRoot, { withFileTypes: true })
|
||||
.filter(entry => entry.name !== 'hooks.json')
|
||||
.sort((left, right) => left.name.localeCompare(right.name))
|
||||
.map(entry => adapter.createScaffoldOperation(
|
||||
module.id,
|
||||
path.join('hooks', entry.name),
|
||||
input
|
||||
)),
|
||||
];
|
||||
}
|
||||
|
||||
function getClaudeManagedDestinationPath(adapter, sourceRelativePath, input) {
|
||||
const normalizedSourcePath = normalizeRelativePath(sourceRelativePath);
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const {
|
||||
@@ -6,42 +5,10 @@ const {
|
||||
createRemappedOperation,
|
||||
isForeignPlatformPath,
|
||||
normalizeRelativePath,
|
||||
planClaudeHooksOperations,
|
||||
} = require('./helpers');
|
||||
|
||||
const CLAUDE_ECC_NAMESPACE = 'ecc';
|
||||
const CLAUDE_HOOKS_CONFIG_PATH = 'hooks/hooks.json';
|
||||
|
||||
function planClaudeHooksOperations(adapter, module, input) {
|
||||
const sourceHooksRoot = path.join(input.repoRoot || '', 'hooks');
|
||||
const operations = [
|
||||
createRemappedOperation(
|
||||
adapter,
|
||||
module.id,
|
||||
CLAUDE_HOOKS_CONFIG_PATH,
|
||||
path.join(adapter.resolveRoot(input), 'settings.json'),
|
||||
{
|
||||
kind: 'update-claude-settings',
|
||||
strategy: 'merge-hook-ids',
|
||||
}
|
||||
),
|
||||
];
|
||||
|
||||
if (!input.repoRoot || !fs.existsSync(sourceHooksRoot)) {
|
||||
return operations;
|
||||
}
|
||||
|
||||
return [
|
||||
...operations,
|
||||
...fs.readdirSync(sourceHooksRoot, { withFileTypes: true })
|
||||
.filter(entry => entry.name !== 'hooks.json')
|
||||
.sort((left, right) => left.name.localeCompare(right.name))
|
||||
.map(entry => adapter.createScaffoldOperation(
|
||||
module.id,
|
||||
path.join('hooks', entry.name),
|
||||
input
|
||||
)),
|
||||
];
|
||||
}
|
||||
|
||||
function getClaudeManagedDestinationPath(adapter, sourceRelativePath, input) {
|
||||
const normalizedSourcePath = normalizeRelativePath(sourceRelativePath);
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const {
|
||||
CLAUDE_HOOKS_CONFIG_PATH,
|
||||
getClaudeSettingsPath,
|
||||
} = require('../install/claude-settings');
|
||||
|
||||
const PLATFORM_SOURCE_PATH_OWNERS = Object.freeze({
|
||||
'.claude-plugin': 'claude',
|
||||
@@ -146,6 +150,42 @@ function createRemappedOperation(adapter, moduleId, sourceRelativePath, destinat
|
||||
});
|
||||
}
|
||||
|
||||
function planClaudeHooksOperations(adapter, module, input) {
|
||||
const operations = [
|
||||
createRemappedOperation(
|
||||
adapter,
|
||||
module.id,
|
||||
CLAUDE_HOOKS_CONFIG_PATH,
|
||||
getClaudeSettingsPath(adapter.resolveRoot(input)),
|
||||
{
|
||||
kind: 'update-claude-settings',
|
||||
strategy: 'merge-hook-ids',
|
||||
}
|
||||
),
|
||||
];
|
||||
|
||||
if (!input.repoRoot) {
|
||||
return operations;
|
||||
}
|
||||
|
||||
const sourceHooksRoot = path.join(input.repoRoot, 'hooks');
|
||||
if (!fs.existsSync(sourceHooksRoot)) {
|
||||
return operations;
|
||||
}
|
||||
|
||||
return [
|
||||
...operations,
|
||||
...fs.readdirSync(sourceHooksRoot, { withFileTypes: true })
|
||||
.filter(entry => entry.name !== 'hooks.json')
|
||||
.sort((left, right) => left.name.localeCompare(right.name))
|
||||
.map(entry => adapter.createScaffoldOperation(
|
||||
module.id,
|
||||
path.join('hooks', entry.name),
|
||||
input
|
||||
)),
|
||||
];
|
||||
}
|
||||
|
||||
function createNamespacedFlatRuleOperations(adapter, moduleId, sourceRelativePath, input = {}) {
|
||||
const normalizedSourcePath = normalizeRelativePath(sourceRelativePath);
|
||||
const sourceRoot = path.join(input.repoRoot || '', normalizedSourcePath);
|
||||
@@ -373,4 +413,5 @@ module.exports = {
|
||||
createRemappedOperation,
|
||||
isForeignPlatformPath,
|
||||
normalizeRelativePath,
|
||||
planClaudeHooksOperations,
|
||||
};
|
||||
|
||||
@@ -11,12 +11,14 @@ const {
|
||||
const { readInstallState, writeInstallState } = require('../install-state');
|
||||
const { assertHookConsentReady, planMaterializesHookRuntime } = require('./hook-consent');
|
||||
const {
|
||||
acquireSettingsLock,
|
||||
getClaudeSettingsPath,
|
||||
mergeManagedHooks,
|
||||
readSettings,
|
||||
runWithSettingsLock,
|
||||
uninstallManagedHooks,
|
||||
updateSettingsAtomic,
|
||||
validateManagedHooks,
|
||||
validateRecordedManagedHooks,
|
||||
} = require('./claude-settings');
|
||||
const { filterMcpConfig, parseDisabledMcpServers } = require('../mcp-config');
|
||||
const { assertWithinTrustedRoot } = require('../path-safety');
|
||||
@@ -293,13 +295,13 @@ function findPreviousManagedHooks(previousState, plan, operation) {
|
||||
|
||||
const previousOperation = (previousState.operations || []).find(candidate => (
|
||||
candidate.kind === operation.kind
|
||||
&& candidate.destinationPath === operation.destinationPath
|
||||
&& comparablePath(candidate.destinationPath) === comparablePath(operation.destinationPath)
|
||||
));
|
||||
if (!previousOperation || !previousOperation.managedHooks) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return validateManagedHooks(
|
||||
return validateRecordedManagedHooks(
|
||||
previousOperation.managedHooks,
|
||||
'previous managed hooks'
|
||||
);
|
||||
@@ -421,19 +423,17 @@ function applyInstallPlan(plan, dependencies = {}) {
|
||||
const isClaudeManualTarget = plan.adapter
|
||||
&& (plan.adapter.target === 'claude' || plan.adapter.target === 'claude-project');
|
||||
const settingsPathToLock = isClaudeManualTarget
|
||||
? path.join(plan.targetRoot, 'settings.json')
|
||||
? getClaudeSettingsPath(plan.targetRoot)
|
||||
: null;
|
||||
if (settingsPathToLock) {
|
||||
assertSafeInstallOperation(plan, { destinationPath: settingsPathToLock });
|
||||
}
|
||||
const releaseSettingsLock = settingsPathToLock
|
||||
? acquireSettingsLock(settingsPathToLock)
|
||||
: null;
|
||||
try {
|
||||
return applyInstallPlanLocked(plan, dependencies, Boolean(releaseSettingsLock));
|
||||
} finally {
|
||||
if (releaseSettingsLock) releaseSettingsLock();
|
||||
}
|
||||
return settingsPathToLock
|
||||
? runWithSettingsLock(
|
||||
settingsPathToLock,
|
||||
() => applyInstallPlanLocked(plan, dependencies, true)
|
||||
)
|
||||
: applyInstallPlanLocked(plan, dependencies, false);
|
||||
}
|
||||
|
||||
function applyInstallPlanLocked(plan, dependencies = {}, settingsLockHeld = false) {
|
||||
@@ -581,7 +581,7 @@ function applyInstallPlanLocked(plan, dependencies = {}, settingsLockHeld = fals
|
||||
|
||||
if (shouldSetClaudeCommitAttributionPreference(appliedPlan)) {
|
||||
writeClaudeCommitAttributionPreference(
|
||||
path.join(plan.targetRoot, 'settings.json'),
|
||||
getClaudeSettingsPath(plan.targetRoot),
|
||||
{ lockHeld: settingsLockHeld }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -1,12 +1,16 @@
|
||||
'use strict';
|
||||
|
||||
const crypto = require('crypto');
|
||||
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',
|
||||
@@ -18,7 +22,6 @@ const EVENTS_WITHOUT_MATCHER = new Set([
|
||||
'UserPromptSubmit', 'Notification', 'Stop', 'SubagentStop',
|
||||
]);
|
||||
const VALID_HOOK_TYPES = new Set(['command', 'http', 'prompt', 'agent']);
|
||||
const INVALID_LOCK_STALE_MS = 5 * 60 * 1000;
|
||||
|
||||
function isJsonObject(value) {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
@@ -44,6 +47,23 @@ 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`);
|
||||
@@ -167,6 +187,30 @@ function validateManagedHooks(managedHooks, label = 'managed hooks') {
|
||||
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`);
|
||||
@@ -210,6 +254,18 @@ function replacePluginRootPlaceholders(value, pluginRoot) {
|
||||
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,
|
||||
@@ -219,9 +275,7 @@ function resolveManagedHookCommands(managedHooks, targetRoot) {
|
||||
...hook,
|
||||
...(typeof hook.command === 'string'
|
||||
? {
|
||||
command: hook.command
|
||||
.split('var e=process.env.CLAUDE_PLUGIN_ROOT;')
|
||||
.join(`var e=${rootExpression};`),
|
||||
command: resolveCommand(hook.command),
|
||||
}
|
||||
: {}),
|
||||
})),
|
||||
@@ -333,139 +387,6 @@ function assertSettingsSnapshotUnchanged(settingsPath, snapshot) {
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
released = true;
|
||||
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}`);
|
||||
}
|
||||
fs.rmSync(quarantinePath, { force: true });
|
||||
};
|
||||
}
|
||||
|
||||
function sameFileIdentity(left, right) {
|
||||
return left.dev === right.dev && left.ino === right.ino;
|
||||
}
|
||||
|
||||
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 updateSettingsAtomic(settingsPath, transform, options = {}) {
|
||||
const update = () => {
|
||||
const maxAttempts = options.maxAttempts || 3;
|
||||
@@ -492,12 +413,7 @@ function updateSettingsAtomic(settingsPath, transform, options = {}) {
|
||||
if (options.lockHeld) {
|
||||
return update();
|
||||
}
|
||||
const releaseLock = acquireSettingsLock(settingsPath);
|
||||
try {
|
||||
return update();
|
||||
} finally {
|
||||
releaseLock();
|
||||
}
|
||||
return runWithSettingsLock(settingsPath, update);
|
||||
}
|
||||
|
||||
function reference(event, id) {
|
||||
@@ -534,7 +450,7 @@ function mergeManagedHooks(settings, managedHooks, options = {}) {
|
||||
const previousHooks = options.previousManagedHooks === undefined
|
||||
|| options.previousManagedHooks === null
|
||||
? null
|
||||
: validateManagedHooks(options.previousManagedHooks, 'previous managed hooks');
|
||||
: 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}`);
|
||||
@@ -677,7 +593,7 @@ function withoutProperty(object, omittedKey) {
|
||||
|
||||
function uninstallManagedHooks(settings, recordedManagedHooks) {
|
||||
const validatedSettings = validateSettings(settings);
|
||||
const recordedHooks = validateManagedHooks(recordedManagedHooks, 'recorded managed hooks');
|
||||
const recordedHooks = validateRecordedManagedHooks(recordedManagedHooks);
|
||||
const currentHooks = validatedSettings.hooks || {};
|
||||
|
||||
for (const [event, recordedEntries] of Object.entries(recordedHooks)) {
|
||||
@@ -735,7 +651,11 @@ function uninstallManagedHooks(settings, recordedManagedHooks) {
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
CLAUDE_HOOKS_CONFIG_PATH,
|
||||
CLAUDE_SETTINGS_FILENAME,
|
||||
acquireSettingsLock,
|
||||
assertClaudeSettingsPath,
|
||||
getClaudeSettingsPath,
|
||||
inspectManagedHooks,
|
||||
materializeManagedHooks,
|
||||
mergeManagedHooks,
|
||||
@@ -743,8 +663,10 @@ module.exports = {
|
||||
readSettings,
|
||||
repairManagedHooks,
|
||||
replacePluginRootPlaceholders,
|
||||
runWithSettingsLock,
|
||||
updateSettingsAtomic,
|
||||
uninstallManagedHooks,
|
||||
validateManagedHooks,
|
||||
validateRecordedManagedHooks,
|
||||
validateSettings,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user