fix(install): harden Claude settings lifecycle

This commit is contained in:
wellkilo
2026-09-07 01:57:04 +08:00
parent 26d3e0038b
commit f59cfd57c2
19 changed files with 540 additions and 350 deletions
+1 -1
View File
@@ -562,7 +562,7 @@ and safe uninstall.
If you installed ECC via `/plugin install`, do not copy those hooks into `settings.json`. Claude Code v2.1+ already auto-loads plugin `hooks/hooks.json`, and duplicating them in `settings.json` causes duplicate execution and cross-platform hook conflicts. If you installed ECC via `/plugin install`, do not copy those hooks into `settings.json`. Claude Code v2.1+ already auto-loads plugin `hooks/hooks.json`, and duplicating them in `settings.json` causes duplicate execution and cross-platform hook conflicts.
On Windows, Claude's config root is `%USERPROFILE%\\.claude`; install the hook runtime with: On Windows, Claude's config root is `%USERPROFILE%\.claude`; install the hook runtime with:
```powershell ```powershell
pwsh -File .\install.ps1 --target claude --modules hooks-runtime --enable-hooks pwsh -File .\install.ps1 --target claude --modules hooks-runtime --enable-hooks
+1 -1
View File
@@ -37,7 +37,7 @@ That installs the hook scripts under `~/.claude/` and registers the resolved
hook entries in `~/.claude/settings.json`. Existing user settings and hook hook entries in `~/.claude/settings.json`. Existing user settings and hook
entries are preserved, while ECC-owned entries are tracked by stable ID for entries are preserved, while ECC-owned entries are tracked by stable ID for
idempotent updates and safe uninstall. On Windows, the Claude config root is idempotent updates and safe uninstall. On Windows, the Claude config root is
`%USERPROFILE%\\.claude`. `%USERPROFILE%\.claude`.
### PreToolUse Hooks ### PreToolUse Hooks
+27 -1
View File
@@ -147,6 +147,24 @@
"type": "string" "type": "string"
} }
} }
},
"managedMatcherEntry": {
"allOf": [
{ "$ref": "#/$defs/matcherEntry" },
{
"type": "object",
"required": ["id"],
"properties": {
"hooks": { "type": "array", "minItems": 1 }
}
}
]
},
"managedMatcherRequiredEntry": {
"allOf": [
{ "$ref": "#/$defs/managedMatcherEntry" },
{ "type": "object", "required": ["matcher"] }
]
} }
}, },
"oneOf": [ "oneOf": [
@@ -180,10 +198,18 @@
"SessionEnd" "SessionEnd"
] ]
}, },
"patternProperties": {
"^(SessionStart|PreToolUse|PermissionRequest|PostToolUse|PostToolUseFailure|SubagentStart|PreCompact|InstructionsLoaded|TeammateIdle|TaskCompleted|ConfigChange|WorktreeCreate|WorktreeRemove|SessionEnd)$": {
"type": "array",
"items": {
"$ref": "#/$defs/managedMatcherRequiredEntry"
}
}
},
"additionalProperties": { "additionalProperties": {
"type": "array", "type": "array",
"items": { "items": {
"$ref": "#/$defs/matcherEntry" "$ref": "#/$defs/managedMatcherEntry"
} }
} }
} }
+2 -20
View File
@@ -218,26 +218,8 @@
"type": "object", "type": "object",
"minProperties": 1, "minProperties": 1,
"propertyNames": { "propertyNames": {
"enum": [ "type": "string",
"SessionStart", "pattern": "\\S"
"UserPromptSubmit",
"PreToolUse",
"PermissionRequest",
"PostToolUse",
"PostToolUseFailure",
"Notification",
"SubagentStart",
"Stop",
"SubagentStop",
"PreCompact",
"InstructionsLoaded",
"TeammateIdle",
"TaskCompleted",
"ConfigChange",
"WorktreeCreate",
"WorktreeRemove",
"SessionEnd"
]
}, },
"additionalProperties": { "additionalProperties": {
"type": "array", "type": "array",
+1 -1
View File
@@ -207,7 +207,7 @@ function validateHooks() {
console.error(`ERROR: ${matcherLabel} has invalid 'matcher' field`); console.error(`ERROR: ${matcherLabel} has invalid 'matcher' field`);
hasErrors = true; 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`); console.error(`ERROR: ${matcherLabel} missing 'hooks' array`);
hasErrors = true; hasErrors = true;
} else { } else {
+24 -38
View File
@@ -3,6 +3,7 @@ const fs = require('fs');
const { execFileSync } = require('child_process'); const { execFileSync } = require('child_process');
const os = require('os'); const os = require('os');
const path = require('path'); const path = require('path');
const { isDeepStrictEqual } = require('util');
const { loadInstallManifests } = require('./install-manifests'); const { loadInstallManifests } = require('./install-manifests');
const { readInstallState, validateInstallState } = require('./install-state'); const { readInstallState, validateInstallState } = require('./install-state');
@@ -22,6 +23,8 @@ const {
} = require('./install/opencode-legacy-migration'); } = require('./install/opencode-legacy-migration');
const { const {
acquireSettingsLock, acquireSettingsLock,
assertClaudeSettingsPath,
getClaudeSettingsPath,
inspectManagedHooks, inspectManagedHooks,
materializeManagedHooks, materializeManagedHooks,
repairManagedHooks, repairManagedHooks,
@@ -532,22 +535,11 @@ function readJsonNoFollow(filePath) {
return JSON.parse(readFileNoFollow(filePath, 'utf8')); return JSON.parse(readFileNoFollow(filePath, 'utf8'));
} }
function expectedClaudeSettingsPath(targetRoot) {
return path.join(targetRoot, 'settings.json');
}
function assertClaudeSettingsDestination(operation, trustedRoot, target = null) { function assertClaudeSettingsDestination(operation, trustedRoot, target = null) {
if (target && target !== 'claude' && target !== 'claude-project') { if (target && target !== 'claude' && target !== 'claude-project') {
throw new Error('Refusing to manage Claude hooks for a non-Claude target.'); throw new Error('Refusing to manage Claude hooks for a non-Claude target.');
} }
if (path.resolve(operation.destinationPath) !== path.resolve( assertClaudeSettingsPath(operation.destinationPath, trustedRoot);
expectedClaudeSettingsPath(trustedRoot)
)) {
throw new Error(
`Refusing to manage Claude hooks outside the canonical settings file: `
+ `${operation.destinationPath}`
);
}
} }
function writeContainedFile(destinationPath, content, trustedRoot, action, mode) { function writeContainedFile(destinationPath, content, trustedRoot, action, mode) {
@@ -714,7 +706,7 @@ function deepRemoveJsonSubset(currentValue, managedValue) {
return currentValue === managedValue ? JSON_REMOVE_SENTINEL : currentValue; return currentValue === managedValue ? JSON_REMOVE_SENTINEL : currentValue;
} }
function hydrateRecordedOperations(repoRoot, operations) { function hydrateRecordedOperations(repoRoot, operations, trustedRoot) {
return operations.map(operation => { return operations.map(operation => {
if (operation.kind === 'update-claude-settings') { if (operation.kind === 'update-claude-settings') {
const sourcePath = resolveOperationSourcePath(repoRoot, operation); const sourcePath = resolveOperationSourcePath(repoRoot, operation);
@@ -729,7 +721,7 @@ function hydrateRecordedOperations(repoRoot, operations) {
previousManagedHooks: operation.managedHooks, previousManagedHooks: operation.managedHooks,
managedHooks: materializeManagedHooks( managedHooks: materializeManagedHooks(
readJsonNoFollow(sourcePath), 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) { function getUnsafeManagedDestinationError(operationHealth) {
const hasFinalSymlink = operationHealth.unsafeDestination.some( const hasFinalSymlink = operationHealth.unsafeDestination.some(
inspection => inspection.reason === 'final-symlink' inspection => inspection.reason === 'final-symlink'
@@ -1806,7 +1792,11 @@ function createRepairPlanFromRecord(record, context, options = {}) {
record.legacyLayout !== 'opencode' record.legacyLayout !== 'opencode'
&& (state.request.legacyMode || shouldRepairFromRecordedOperations(state)) && (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); const statePreview = buildRecordedStatePreview(state, context, operations);
return { return {
@@ -1951,15 +1941,14 @@ function repairInstalledStates(options = {}) {
let releaseSettingsLock = null; let releaseSettingsLock = null;
try { try {
if ( const settingsPathToLock = !options.dryRun
!options.dryRun
&& getManagedOperations(record.state || {}).some( && getManagedOperations(record.state || {}).some(
operation => operation.kind === 'update-claude-settings' operation => operation.kind === 'update-claude-settings'
) )
) { ? getClaudeSettingsPath(record.targetRoot)
releaseSettingsLock = acquireSettingsLock( : null;
path.join(record.targetRoot, 'settings.json') if (settingsPathToLock) {
); releaseSettingsLock = acquireSettingsLock(settingsPathToLock);
} }
const needsOpencodeBuild = record.adapter.target === 'opencode' const needsOpencodeBuild = record.adapter.target === 'opencode'
&& hasOpencodeBuildError(getOpencodeBuildValidationIssues(context)); && hasOpencodeBuildError(getOpencodeBuildValidationIssues(context));
@@ -2107,16 +2096,13 @@ function repairInstalledStates(options = {}) {
const repairOperations = [ const repairOperations = [
...operationHealth.missing.map(entry => ({ ...entry.operation })), ...operationHealth.missing.map(entry => ({ ...entry.operation })),
...operationHealth.drifted.map(entry => ({ ...entry.operation })), ...operationHealth.drifted.map(entry => ({ ...entry.operation })),
...hookRepairOperations({ ...desiredPlan.operations
drifted: desiredPlan.operations .filter(operation => (
.filter(operation => ( operation.kind === 'update-claude-settings'
operation.kind === 'update-claude-settings' && operation.previousManagedHooks
&& operation.previousManagedHooks && !isDeepStrictEqual(operation.previousManagedHooks, operation.managedHooks)
&& JSON.stringify(operation.previousManagedHooks) ))
!== JSON.stringify(operation.managedHooks) .map(operation => ({ ...operation })),
))
.map(operation => ({ operation })),
}),
].filter((operation, index, items) => items.findIndex(candidate => ( ].filter((operation, index, items) => items.findIndex(candidate => (
candidate.kind === operation.kind candidate.kind === operation.kind
&& candidate.destinationPath === operation.destinationPath && candidate.destinationPath === operation.destinationPath
@@ -2333,7 +2319,7 @@ function uninstallInstalledStates(options = {}) {
const operations = getManagedOperations(state); const operations = getManagedOperations(state);
if (operations.some(operation => operation.kind === 'update-claude-settings')) { if (operations.some(operation => operation.kind === 'update-claude-settings')) {
releaseSettingsLock = acquireSettingsLock( releaseSettingsLock = acquireSettingsLock(
path.join(record.targetRoot, 'settings.json') getClaudeSettingsPath(record.targetRoot)
); );
} }
+8 -4
View File
@@ -1,6 +1,10 @@
const fs = require('fs'); const fs = require('fs');
const path = require('path'); 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 // Dependency-free, self-contained validation. The installer closure must not
// require any non-builtin package (enterprise supply-chain vetting: the vetted // require any non-builtin package (enterprise supply-chain vetting: the vetted
@@ -217,14 +221,14 @@ function createFallbackValidator() {
if (operation.moduleId !== 'hooks-runtime') { if (operation.moduleId !== 'hooks-runtime') {
pushError(`${instancePath}/moduleId`, 'must equal 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'); pushError(`${instancePath}/sourceRelativePath`, 'must equal hooks/hooks.json');
} }
if ( if (
isNonEmptyString(state.target && state.target.root) isNonEmptyString(state.target && state.target.root)
&& isNonEmptyString(operation.destinationPath) && 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 actualDestination = path.resolve(operation.destinationPath);
const pathsMatch = process.platform === 'win32' const pathsMatch = process.platform === 'win32'
? expectedDestination.toLowerCase() === actualDestination.toLowerCase() ? expectedDestination.toLowerCase() === actualDestination.toLowerCase()
@@ -237,7 +241,7 @@ function createFallbackValidator() {
} }
} }
try { try {
validateManagedHooks(operation.managedHooks); validateRecordedManagedHooks(operation.managedHooks);
} catch (error) { } catch (error) {
pushError(`${instancePath}/managedHooks`, error.message); pushError(`${instancePath}/managedHooks`, error.message);
} }
+1 -34
View File
@@ -1,4 +1,3 @@
const fs = require('fs');
const path = require('path'); const path = require('path');
const { const {
@@ -6,42 +5,10 @@ const {
createRemappedOperation, createRemappedOperation,
isForeignPlatformPath, isForeignPlatformPath,
normalizeRelativePath, normalizeRelativePath,
planClaudeHooksOperations,
} = require('./helpers'); } = require('./helpers');
const CLAUDE_ECC_NAMESPACE = 'ecc'; 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) { function getClaudeManagedDestinationPath(adapter, sourceRelativePath, input) {
const normalizedSourcePath = normalizeRelativePath(sourceRelativePath); const normalizedSourcePath = normalizeRelativePath(sourceRelativePath);
+1 -34
View File
@@ -1,4 +1,3 @@
const fs = require('fs');
const path = require('path'); const path = require('path');
const { const {
@@ -6,42 +5,10 @@ const {
createRemappedOperation, createRemappedOperation,
isForeignPlatformPath, isForeignPlatformPath,
normalizeRelativePath, normalizeRelativePath,
planClaudeHooksOperations,
} = require('./helpers'); } = require('./helpers');
const CLAUDE_ECC_NAMESPACE = 'ecc'; 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) { function getClaudeManagedDestinationPath(adapter, sourceRelativePath, input) {
const normalizedSourcePath = normalizeRelativePath(sourceRelativePath); const normalizedSourcePath = normalizeRelativePath(sourceRelativePath);
+41
View File
@@ -1,6 +1,10 @@
const fs = require('fs'); const fs = require('fs');
const os = require('os'); const os = require('os');
const path = require('path'); const path = require('path');
const {
CLAUDE_HOOKS_CONFIG_PATH,
getClaudeSettingsPath,
} = require('../install/claude-settings');
const PLATFORM_SOURCE_PATH_OWNERS = Object.freeze({ const PLATFORM_SOURCE_PATH_OWNERS = Object.freeze({
'.claude-plugin': 'claude', '.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 = {}) { function createNamespacedFlatRuleOperations(adapter, moduleId, sourceRelativePath, input = {}) {
const normalizedSourcePath = normalizeRelativePath(sourceRelativePath); const normalizedSourcePath = normalizeRelativePath(sourceRelativePath);
const sourceRoot = path.join(input.repoRoot || '', normalizedSourcePath); const sourceRoot = path.join(input.repoRoot || '', normalizedSourcePath);
@@ -373,4 +413,5 @@ module.exports = {
createRemappedOperation, createRemappedOperation,
isForeignPlatformPath, isForeignPlatformPath,
normalizeRelativePath, normalizeRelativePath,
planClaudeHooksOperations,
}; };
+13 -13
View File
@@ -11,12 +11,14 @@ const {
const { readInstallState, writeInstallState } = require('../install-state'); const { readInstallState, writeInstallState } = require('../install-state');
const { assertHookConsentReady, planMaterializesHookRuntime } = require('./hook-consent'); const { assertHookConsentReady, planMaterializesHookRuntime } = require('./hook-consent');
const { const {
acquireSettingsLock, getClaudeSettingsPath,
mergeManagedHooks, mergeManagedHooks,
readSettings, readSettings,
runWithSettingsLock,
uninstallManagedHooks, uninstallManagedHooks,
updateSettingsAtomic, updateSettingsAtomic,
validateManagedHooks, validateManagedHooks,
validateRecordedManagedHooks,
} = require('./claude-settings'); } = require('./claude-settings');
const { filterMcpConfig, parseDisabledMcpServers } = require('../mcp-config'); const { filterMcpConfig, parseDisabledMcpServers } = require('../mcp-config');
const { assertWithinTrustedRoot } = require('../path-safety'); const { assertWithinTrustedRoot } = require('../path-safety');
@@ -293,13 +295,13 @@ function findPreviousManagedHooks(previousState, plan, operation) {
const previousOperation = (previousState.operations || []).find(candidate => ( const previousOperation = (previousState.operations || []).find(candidate => (
candidate.kind === operation.kind candidate.kind === operation.kind
&& candidate.destinationPath === operation.destinationPath && comparablePath(candidate.destinationPath) === comparablePath(operation.destinationPath)
)); ));
if (!previousOperation || !previousOperation.managedHooks) { if (!previousOperation || !previousOperation.managedHooks) {
return null; return null;
} }
return validateManagedHooks( return validateRecordedManagedHooks(
previousOperation.managedHooks, previousOperation.managedHooks,
'previous managed hooks' 'previous managed hooks'
); );
@@ -421,19 +423,17 @@ function applyInstallPlan(plan, dependencies = {}) {
const isClaudeManualTarget = plan.adapter const isClaudeManualTarget = plan.adapter
&& (plan.adapter.target === 'claude' || plan.adapter.target === 'claude-project'); && (plan.adapter.target === 'claude' || plan.adapter.target === 'claude-project');
const settingsPathToLock = isClaudeManualTarget const settingsPathToLock = isClaudeManualTarget
? path.join(plan.targetRoot, 'settings.json') ? getClaudeSettingsPath(plan.targetRoot)
: null; : null;
if (settingsPathToLock) { if (settingsPathToLock) {
assertSafeInstallOperation(plan, { destinationPath: settingsPathToLock }); assertSafeInstallOperation(plan, { destinationPath: settingsPathToLock });
} }
const releaseSettingsLock = settingsPathToLock return settingsPathToLock
? acquireSettingsLock(settingsPathToLock) ? runWithSettingsLock(
: null; settingsPathToLock,
try { () => applyInstallPlanLocked(plan, dependencies, true)
return applyInstallPlanLocked(plan, dependencies, Boolean(releaseSettingsLock)); )
} finally { : applyInstallPlanLocked(plan, dependencies, false);
if (releaseSettingsLock) releaseSettingsLock();
}
} }
function applyInstallPlanLocked(plan, dependencies = {}, settingsLockHeld = false) { function applyInstallPlanLocked(plan, dependencies = {}, settingsLockHeld = false) {
@@ -581,7 +581,7 @@ function applyInstallPlanLocked(plan, dependencies = {}, settingsLockHeld = fals
if (shouldSetClaudeCommitAttributionPreference(appliedPlan)) { if (shouldSetClaudeCommitAttributionPreference(appliedPlan)) {
writeClaudeCommitAttributionPreference( writeClaudeCommitAttributionPreference(
path.join(plan.targetRoot, 'settings.json'), getClaudeSettingsPath(plan.targetRoot),
{ lockHeld: settingsLockHeld } { lockHeld: settingsLockHeld }
); );
} }
+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,
};
+68 -146
View File
@@ -1,12 +1,16 @@
'use strict'; 'use strict';
const crypto = require('crypto');
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const { isDeepStrictEqual } = require('util'); const { isDeepStrictEqual } = require('util');
const { writeFileAtomic } = require('../atomic-write'); 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_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([ const VALID_EVENTS = new Set([
'SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PermissionRequest', 'SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PermissionRequest',
'PostToolUse', 'PostToolUseFailure', 'Notification', 'SubagentStart', 'PostToolUse', 'PostToolUseFailure', 'Notification', 'SubagentStart',
@@ -18,7 +22,6 @@ const EVENTS_WITHOUT_MATCHER = new Set([
'UserPromptSubmit', 'Notification', 'Stop', 'SubagentStop', 'UserPromptSubmit', 'Notification', 'Stop', 'SubagentStop',
]); ]);
const VALID_HOOK_TYPES = new Set(['command', 'http', 'prompt', 'agent']); const VALID_HOOK_TYPES = new Set(['command', 'http', 'prompt', 'agent']);
const INVALID_LOCK_STALE_MS = 5 * 60 * 1000;
function isJsonObject(value) { function isJsonObject(value) {
if (!value || typeof value !== 'object' || Array.isArray(value)) { if (!value || typeof value !== 'object' || Array.isArray(value)) {
@@ -44,6 +47,23 @@ function isNonEmptyString(value) {
return typeof value === 'string' && value.trim() !== ''; 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) { function validateHookHandler(hook, label) {
if (!isJsonObject(hook)) { if (!isJsonObject(hook)) {
throw new Error(`Invalid managed hook handler at ${label}: expected a JSON object`); 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); 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') { function validateSettings(settings, label = 'Claude settings') {
if (!isJsonObject(settings)) { if (!isJsonObject(settings)) {
throw new Error(`Invalid ${label}: expected a JSON object`); throw new Error(`Invalid ${label}: expected a JSON object`);
@@ -210,6 +254,18 @@ function replacePluginRootPlaceholders(value, pluginRoot) {
function resolveManagedHookCommands(managedHooks, targetRoot) { function resolveManagedHookCommands(managedHooks, targetRoot) {
const encodedRoot = Buffer.from(targetRoot, 'utf8').toString('base64'); const encodedRoot = Buffer.from(targetRoot, 'utf8').toString('base64');
const rootExpression = `Buffer.from('${encodedRoot}','base64').toString('utf8')`; 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( return Object.fromEntries(
Object.entries(managedHooks).map(([event, entries]) => [ Object.entries(managedHooks).map(([event, entries]) => [
event, event,
@@ -219,9 +275,7 @@ function resolveManagedHookCommands(managedHooks, targetRoot) {
...hook, ...hook,
...(typeof hook.command === 'string' ...(typeof hook.command === 'string'
? { ? {
command: hook.command command: resolveCommand(hook.command),
.split('var e=process.env.CLAUDE_PLUGIN_ROOT;')
.join(`var e=${rootExpression};`),
} }
: {}), : {}),
})), })),
@@ -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 = {}) { function updateSettingsAtomic(settingsPath, transform, options = {}) {
const update = () => { const update = () => {
const maxAttempts = options.maxAttempts || 3; const maxAttempts = options.maxAttempts || 3;
@@ -492,12 +413,7 @@ function updateSettingsAtomic(settingsPath, transform, options = {}) {
if (options.lockHeld) { if (options.lockHeld) {
return update(); return update();
} }
const releaseLock = acquireSettingsLock(settingsPath); return runWithSettingsLock(settingsPath, update);
try {
return update();
} finally {
releaseLock();
}
} }
function reference(event, id) { function reference(event, id) {
@@ -534,7 +450,7 @@ function mergeManagedHooks(settings, managedHooks, options = {}) {
const previousHooks = options.previousManagedHooks === undefined const previousHooks = options.previousManagedHooks === undefined
|| options.previousManagedHooks === null || options.previousManagedHooks === null
? null ? null
: validateManagedHooks(options.previousManagedHooks, 'previous managed hooks'); : validateRecordedManagedHooks(options.previousManagedHooks, 'previous managed hooks');
const repair = options.mode === 'repair' || options.repair === true; const repair = options.mode === 'repair' || options.repair === true;
if (options.mode !== undefined && options.mode !== 'merge' && options.mode !== 'repair') { if (options.mode !== undefined && options.mode !== 'merge' && options.mode !== 'repair') {
throw new Error(`Unknown Claude settings merge mode: ${options.mode}`); throw new Error(`Unknown Claude settings merge mode: ${options.mode}`);
@@ -677,7 +593,7 @@ function withoutProperty(object, omittedKey) {
function uninstallManagedHooks(settings, recordedManagedHooks) { function uninstallManagedHooks(settings, recordedManagedHooks) {
const validatedSettings = validateSettings(settings); const validatedSettings = validateSettings(settings);
const recordedHooks = validateManagedHooks(recordedManagedHooks, 'recorded managed hooks'); const recordedHooks = validateRecordedManagedHooks(recordedManagedHooks);
const currentHooks = validatedSettings.hooks || {}; const currentHooks = validatedSettings.hooks || {};
for (const [event, recordedEntries] of Object.entries(recordedHooks)) { for (const [event, recordedEntries] of Object.entries(recordedHooks)) {
@@ -735,7 +651,11 @@ function uninstallManagedHooks(settings, recordedManagedHooks) {
} }
module.exports = { module.exports = {
CLAUDE_HOOKS_CONFIG_PATH,
CLAUDE_SETTINGS_FILENAME,
acquireSettingsLock, acquireSettingsLock,
assertClaudeSettingsPath,
getClaudeSettingsPath,
inspectManagedHooks, inspectManagedHooks,
materializeManagedHooks, materializeManagedHooks,
mergeManagedHooks, mergeManagedHooks,
@@ -743,8 +663,10 @@ module.exports = {
readSettings, readSettings,
repairManagedHooks, repairManagedHooks,
replacePluginRootPlaceholders, replacePluginRootPlaceholders,
runWithSettingsLock,
updateSettingsAtomic, updateSettingsAtomic,
uninstallManagedHooks, uninstallManagedHooks,
validateManagedHooks, validateManagedHooks,
validateRecordedManagedHooks,
validateSettings, validateSettings,
}; };
+31
View File
@@ -2694,6 +2694,37 @@ function runTests() {
cleanupTestDir(testDir); cleanupTestDir(testDir);
})) passed++; else failed++; })) passed++; else failed++;
if (test('rejects wrapped matcher entry missing a required matcher', () => {
const testDir = createTestDir();
const hooksFile = path.join(testDir, 'hooks.json');
fs.writeFileSync(hooksFile, JSON.stringify({
hooks: {
SessionStart: [{
id: 'test:missing-matcher',
hooks: [{ type: 'command', command: 'echo start' }]
}]
}
}));
const result = runValidatorWithDir('validate-hooks', 'HOOKS_FILE', hooksFile);
assert.strictEqual(result.code, 1);
assert.ok(result.stderr.includes('matcher'), result.stderr);
cleanupTestDir(testDir);
})) passed++; else failed++;
if (test('rejects wrapped matcher entry with an empty handlers array', () => {
const testDir = createTestDir();
const hooksFile = path.join(testDir, 'hooks.json');
fs.writeFileSync(hooksFile, JSON.stringify({
hooks: { Stop: [{ id: 'test:empty-handlers', hooks: [] }] }
}));
const result = runValidatorWithDir('validate-hooks', 'HOOKS_FILE', hooksFile);
assert.strictEqual(result.code, 1);
assert.ok(result.stderr.includes('hooks'), result.stderr);
cleanupTestDir(testDir);
})) passed++; else failed++;
if (test('rejects wrapped matcher entry with whitespace-only id', () => { if (test('rejects wrapped matcher entry with whitespace-only id', () => {
const testDir = createTestDir(); const testDir = createTestDir();
const hooksFile = path.join(testDir, 'hooks.json'); const hooksFile = path.join(testDir, 'hooks.json');
+118 -4
View File
@@ -10,6 +10,8 @@ const os = require('os');
const path = require('path'); const path = require('path');
const { const {
runWithSettingsLock,
materializeManagedHooks,
inspectManagedHooks, inspectManagedHooks,
mergeManagedHooks, mergeManagedHooks,
parseSettings, parseSettings,
@@ -78,15 +80,49 @@ function runTests() {
{ BogusEvent: [entry('bad:event', 'bad')] }, { BogusEvent: [entry('bad:event', 'bad')] },
{ SessionStart: [{ id: 'missing:hooks', matcher: '.*' }] }, { SessionStart: [{ id: 'missing:hooks', matcher: '.*' }] },
{ SessionStart: [{ id: 'bad:command', matcher: '.*', hooks: [{ type: 'command' }] }] }, { SessionStart: [{ id: 'bad:command', matcher: '.*', hooks: [{ type: 'command' }] }] },
{
SessionStart: [{ id: 'shared' }],
Stop: [{ id: 'shared' }],
},
]; ];
for (const invalid of invalidValues) { for (const invalid of invalidValues) {
assert.throws(() => validateManagedHooks(invalid), /managed hooks|hook entry|unique id/i); assert.throws(() => validateManagedHooks(invalid), /managed hooks|hook entry|unique id/i);
} }
assert.throws(
() => validateManagedHooks({
Stop: [entry('shared', 'a')],
SubagentStop: [entry('shared', 'b')],
}),
/expected globally unique id "shared"/
);
})) passed++; else failed++;
if (test('materializes hook roots and rejects unresolved environment references', () => {
const source = {
hooks: {
Stop: [entry(
'ecc:stop',
'var e=process.env.CLAUDE_PLUGIN_ROOT; '
+ 'process.env.CLAUDE_PLUGIN_ROOT=r; ${CLAUDE_PLUGIN_ROOT}'
)],
},
};
const before = clone(source);
const materialized = materializeManagedHooks(source, '/opt/ecc');
const command = materialized.Stop[0].hooks[0].command;
const encodedRoot = command.match(/Buffer\.from\('([^']+)','base64'\)/)[1];
assert.deepStrictEqual(source, before);
assert.ok(!command.includes('var e=process.env.CLAUDE_PLUGIN_ROOT;'));
assert.ok(!command.includes('${CLAUDE_PLUGIN_ROOT}'));
assert.strictEqual(Buffer.from(encodedRoot, 'base64').toString('utf8'), '/opt/ecc');
assert.throws(() => materializeManagedHooks({}, '/opt/ecc'), /hooks object/);
assert.throws(() => materializeManagedHooks(source, ''), /target root/);
assert.throws(
() => materializeManagedHooks({
hooks: {
Stop: [entry('ecc:stop', 'node -e "const e=process.env.CLAUDE_PLUGIN_ROOT"')],
},
}, '/opt/ecc'),
/Unable to resolve CLAUDE_PLUGIN_ROOT/
);
})) passed++; else failed++; })) passed++; else failed++;
if (test('replaces every plugin-root placeholder recursively and immutably', () => { if (test('replaces every plugin-root placeholder recursively and immutably', () => {
@@ -234,6 +270,71 @@ function runTests() {
} }
})) passed++; else failed++; })) passed++; else failed++;
if (test('atomic settings updates honor an already-held lock', () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-settings-lock-held-'));
const settingsPath = path.join(tempDir, 'settings.json');
const lockPath = `${settingsPath}.ecc.lock`;
try {
fs.writeFileSync(lockPath, JSON.stringify({ pid: process.pid }), { mode: 0o600 });
updateSettingsAtomic(
settingsPath,
settings => ({ settings: { ...settings, held: true } }),
{ lockHeld: true }
);
assert.deepStrictEqual(JSON.parse(fs.readFileSync(settingsPath, 'utf8')), { held: true });
assert.ok(fs.existsSync(lockPath));
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
})) passed++; else failed++;
if (test('settings lock release failures do not replace the primary update error', () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-settings-release-error-'));
const settingsPath = path.join(tempDir, 'settings.json');
const lockPath = `${settingsPath}.ecc.lock`;
try {
let caught;
try {
runWithSettingsLock(settingsPath, () => {
fs.rmSync(lockPath, { force: true });
throw new Error('primary settings failure');
});
} catch (error) {
caught = error;
}
assert.ok(caught);
assert.strictEqual(caught.message, 'primary settings failure');
assert.ok(caught.releaseError);
assert.strictEqual(caught.releaseError.code, 'ENOENT');
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
})) passed++; else failed++;
if (test('atomic settings updates refuse a symlinked destination', () => {
if (process.platform === 'win32') {
console.log(' (file symlink support is environment-dependent on Windows; skipping)');
return;
}
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-settings-symlink-'));
const realPath = path.join(tempDir, 'real.json');
const settingsPath = path.join(tempDir, 'settings.json');
try {
fs.writeFileSync(realPath, '{"theme":"dark"}\n', { mode: 0o600 });
fs.symlinkSync(realPath, settingsPath);
assert.throws(
() => updateSettingsAtomic(
settingsPath,
settings => ({ settings: { ...settings, managed: true } })
),
error => error.code === 'ELOOP' || error.code === 'ECC_SETTINGS_CHANGED'
);
assert.deepStrictEqual(JSON.parse(fs.readFileSync(realPath, 'utf8')), { theme: 'dark' });
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
})) passed++; else failed++;
if (test('fresh merge appends managed entries while preserving unrelated settings and hooks', () => { if (test('fresh merge appends managed entries while preserving unrelated settings and hooks', () => {
const userEntry = { matcher: 'Bash', hooks: [{ type: 'command', command: 'user-hook' }] }; const userEntry = { matcher: 'Bash', hooks: [{ type: 'command', command: 'user-hook' }] };
const settings = { const settings = {
@@ -540,6 +641,19 @@ function runTests() {
assert.deepStrictEqual(result.retained, []); assert.deepStrictEqual(result.retained, []);
})) passed++; else failed++; })) passed++; else failed++;
if (test('uninstall accepts structurally valid hooks from an older runtime contract', () => {
const recorded = {
LegacyEvent: [{
id: 'ecc:legacy',
hooks: [{ type: 'legacy-handler', payload: { version: 1 } }],
}],
};
const result = uninstallManagedHooks({ hooks: clone(recorded) }, recorded);
assert.deepStrictEqual(result.settings, {});
assert.deepStrictEqual(result.removed, [{ event: 'LegacyEvent', id: 'ecc:legacy' }]);
})) passed++; else failed++;
if (test('all settings transforms reject non-array hook events before changing data', () => { if (test('all settings transforms reject non-array hook events before changing data', () => {
const settings = { hooks: { Stop: 'invalid' } }; const settings = { hooks: { Stop: 'invalid' } };
const managed = { Stop: [entry('ecc:stop', 'expected')] }; const managed = { Stop: [entry('ecc:stop', 'expected')] };
+2 -16
View File
@@ -182,14 +182,7 @@ function runTests() {
target: 'claude', target: 'claude',
moduleIds: ['hooks-runtime'], moduleIds: ['hooks-runtime'],
}); });
const plan = { const plan = withHookConsent(rawPlan, 'enabled');
...rawPlan,
hookConsent: 'enabled',
statePreview: {
...rawPlan.statePreview,
request: { ...rawPlan.statePreview.request, hookConsent: 'enabled' },
},
};
const settingsPath = path.join(homeDir, '.claude', 'settings.json'); const settingsPath = path.join(homeDir, '.claude', 'settings.json');
applyInstallPlanDirect(plan, { applyInstallPlanDirect(plan, {
@@ -584,14 +577,7 @@ function runTests() {
target: 'claude', target: 'claude',
moduleIds: ['hooks-runtime'], moduleIds: ['hooks-runtime'],
}); });
const plan = { const plan = withHookConsent(rawPlan, 'enabled');
...rawPlan,
hookConsent: 'enabled',
statePreview: {
...rawPlan.statePreview,
request: { ...rawPlan.statePreview.request, hookConsent: 'enabled' },
},
};
const settingsPath = path.join(homeDir, '.claude', 'settings.json'); const settingsPath = path.join(homeDir, '.claude', 'settings.json');
let settingsCommitCount = 0; let settingsCommitCount = 0;
fs.renameSync = function failAttributionCommit(sourcePath, destinationPath) { fs.renameSync = function failAttributionCommit(sourcePath, destinationPath) {
+11 -13
View File
@@ -23,7 +23,10 @@ const {
readInstallState, readInstallState,
writeInstallState, writeInstallState,
} = require('../../scripts/lib/install-state'); } = require('../../scripts/lib/install-state');
const { materializeManagedHooks } = require('../../scripts/lib/install/claude-settings'); const {
assertClaudeSettingsPath,
materializeManagedHooks,
} = require('../../scripts/lib/install/claude-settings');
const REPO_ROOT = path.join(__dirname, '..', '..'); const REPO_ROOT = path.join(__dirname, '..', '..');
const CURRENT_PACKAGE_VERSION = JSON.parse( const CURRENT_PACKAGE_VERSION = JSON.parse(
@@ -3479,7 +3482,10 @@ function runTests() {
})) passed++; else failed++; })) passed++; else failed++;
if (test('repair creates missing Claude settings with private permissions', () => { if (test('repair creates missing Claude settings with private permissions', () => {
if (process.platform === 'win32') return; if (process.platform === 'win32') {
console.log(' (POSIX file modes unsupported on this platform; skipping)');
return;
}
const homeDir = createTempDir('install-lifecycle-claude-home-'); const homeDir = createTempDir('install-lifecycle-claude-home-');
const projectRoot = createTempDir('install-lifecycle-project-'); const projectRoot = createTempDir('install-lifecycle-project-');
@@ -3710,7 +3716,6 @@ function runTests() {
assert.strictEqual(doctor.results[0].status, 'error'); assert.strictEqual(doctor.results[0].status, 'error');
assert.ok(doctor.results[0].issues.some(issue => ( assert.ok(doctor.results[0].issues.some(issue => (
issue.code === 'unsafe-managed-destination' issue.code === 'unsafe-managed-destination'
|| issue.code === 'invalid-install-state'
))); )));
assert.strictEqual(repair.results[0].status, 'error'); assert.strictEqual(repair.results[0].status, 'error');
assert.match(repair.results[0].error, /final symlink/); assert.match(repair.results[0].error, /final symlink/);
@@ -3726,24 +3731,17 @@ function runTests() {
} }
})) passed++; else failed++; })) passed++; else failed++;
if (test('Claude settings lifecycle refuses a non-canonical settings destination', () => { if (test('Claude settings path validation refuses a non-canonical destination', () => {
const homeDir = createTempDir('install-lifecycle-claude-home-'); const homeDir = createTempDir('install-lifecycle-claude-home-');
const projectRoot = createTempDir('install-lifecycle-project-'); const projectRoot = createTempDir('install-lifecycle-project-');
try { try {
const targetRoot = path.join(homeDir, '.claude'); const targetRoot = path.join(homeDir, '.claude');
const destinationPath = path.join(targetRoot, 'settings.local.json'); const destinationPath = path.join(targetRoot, 'settings.local.json');
const managedHooks = currentManagedHooks(targetRoot);
fs.mkdirSync(targetRoot, { recursive: true }); fs.mkdirSync(targetRoot, { recursive: true });
assert.throws( assert.throws(
() => writeClaudeState(homeDir, { () => assertClaudeSettingsPath(destinationPath, targetRoot),
operations: [ /outside the canonical settings file/
managedOperation('update-claude-settings', destinationPath, {
managedHooks,
}),
],
}),
/canonical Claude settings path/
); );
assert.ok(!fs.existsSync(destinationPath)); assert.ok(!fs.existsSync(destinationPath));
} finally { } finally {
+11 -21
View File
@@ -169,28 +169,18 @@ function runTests() {
}, },
}], }],
}), }),
/managedHooks.*non-empty unique id/ /managedHooks.*Invalid hook entry/
);
assert.throws(
() => createInstallState({
...baseOptions,
operations: [{
...operation,
managedHooks: {
SessionStart: [{
id: 'duplicate',
matcher: '.*',
hooks: [{ type: 'command', command: 'node start.js' }],
}],
Stop: [{
id: 'duplicate',
hooks: [{ type: 'command', command: 'node stop.js' }],
}],
},
}],
}),
/managedHooks.*globally unique id/
); );
assert.doesNotThrow(() => createInstallState({
...baseOptions,
operations: [{
...operation,
managedHooks: {
SessionStart: [{ id: 'shared', hooks: [] }],
LegacyEvent: [{ id: 'shared', hooks: [{ type: 'legacy' }] }],
},
}],
}));
})) passed++; else failed++; })) passed++; else failed++;
if (test('writes and reads install-state from disk', () => { if (test('writes and reads install-state from disk', () => {
@@ -8,6 +8,12 @@ const path = require('path');
const README = path.join(__dirname, '..', '..', 'README.md'); const README = path.join(__dirname, '..', '..', 'README.md');
const HOOKS_README = path.join(__dirname, '..', '..', 'hooks', 'README.md'); const HOOKS_README = path.join(__dirname, '..', '..', 'hooks', 'README.md');
const HOOK_REGISTRATION_PHRASE =
'registers the resolved hook entries in `~/.claude/settings.json`';
function normalizeWhitespace(text) {
return text.replace(/\s+/g, ' ');
}
function test(name, fn) { function test(name, fn) {
try { try {
@@ -44,11 +50,11 @@ function runTests() {
'README should document the supported PowerShell hook install path' 'README should document the supported PowerShell hook install path'
); );
assert.ok( assert.ok(
readme.includes('%USERPROFILE%\\\\.claude'), readme.includes('%USERPROFILE%\\.claude'),
'README should call out the correct Windows Claude config root' 'README should call out the correct Windows Claude config root'
); );
assert.ok( assert.ok(
readme.includes('registers the resolved\nhook entries in `~/.claude/settings.json`'), normalizeWhitespace(readme).includes(HOOK_REGISTRATION_PHRASE),
'README should explain that manual installs register hooks in Claude settings' 'README should explain that manual installs register hooks in Claude settings'
); );
})) passed++; else failed++; })) passed++; else failed++;
@@ -67,7 +73,7 @@ function runTests() {
'hooks/README should document the supported PowerShell hook install path' 'hooks/README should document the supported PowerShell hook install path'
); );
assert.ok( assert.ok(
hooksReadme.includes('registers the resolved\nhook entries in `~/.claude/settings.json`'), normalizeWhitespace(hooksReadme).includes(HOOK_REGISTRATION_PHRASE),
'hooks/README should explain that manual installs register hooks in Claude settings' 'hooks/README should explain that manual installs register hooks in Claude settings'
); );
})) passed++; else failed++; })) passed++; else failed++;