fix: isolate Claude project hooks from ESM hosts (#3184)

* fix: isolate Claude project hooks from ESM hosts

* fix: handle user-owned Claude scripts package
This commit is contained in:
Eastern
2026-09-20 14:26:49 -04:00
committed by GitHub
parent 111387afe4
commit 95448ad81d
4 changed files with 141 additions and 1 deletions
+68
View File
@@ -1056,6 +1056,74 @@ function runTests() {
}
})) passed++; else failed++;
if (test('isolates project hooks from ESM package scopes without overwriting user Claude package data', () => {
const homeDir = createTempDir('install-apply-claude-project-esm-home-');
const projectDir = createTempDir('install-apply-claude-project-esm-');
const claudeRoot = path.join(projectDir, '.claude');
const userPackagePath = path.join(claudeRoot, 'package.json');
const scriptsPackagePath = path.join(claudeRoot, 'scripts', 'package.json');
const hooksPackagePath = path.join(claudeRoot, 'scripts', 'hooks', 'package.json');
const libPackagePath = path.join(claudeRoot, 'scripts', 'lib', 'package.json');
const userPackage = '{"name":"user-claude-config","type":"module"}\n';
const userScriptsPackage = '{"name":"user-claude-scripts","type":"module"}\n';
try {
fs.writeFileSync(path.join(projectDir, 'package.json'), '{"type":"module"}\n');
fs.mkdirSync(path.dirname(scriptsPackagePath), { recursive: true });
fs.writeFileSync(userPackagePath, userPackage);
fs.writeFileSync(scriptsPackagePath, userScriptsPackage);
const firstInstall = run(
['--target', 'claude-project', '--profile', 'core', '--enable-hooks'],
{ cwd: projectDir, homeDir }
);
assert.strictEqual(firstInstall.code, 0, firstInstall.stderr);
assert.strictEqual(fs.readFileSync(userPackagePath, 'utf8'), userPackage);
assert.strictEqual(fs.readFileSync(scriptsPackagePath, 'utf8'), userScriptsPackage);
assert.deepStrictEqual(readJson(hooksPackagePath), { type: 'commonjs' });
assert.deepStrictEqual(readJson(libPackagePath), { type: 'commonjs' });
const hookResult = spawnSync(
process.execPath,
[path.join(claudeRoot, 'scripts', 'hooks', 'block-no-verify.js')],
{
input: JSON.stringify({ tool_input: { command: 'git commit --no-verify' } }),
encoding: 'utf8',
cwd: projectDir,
}
);
assert.strictEqual(hookResult.status, 2, hookResult.stderr);
assert.match(hookResult.stderr, /no-verify/i);
const secondInstall = run(
['--target', 'claude-project', '--profile', 'core', '--enable-hooks'],
{ cwd: projectDir, homeDir }
);
assert.strictEqual(secondInstall.code, 0, secondInstall.stderr);
assert.strictEqual(fs.readFileSync(userPackagePath, 'utf8'), userPackage);
assert.strictEqual(fs.readFileSync(scriptsPackagePath, 'utf8'), userScriptsPackage);
const state = readJson(path.join(claudeRoot, 'ecc', 'install-state.json'));
const boundaryPaths = [hooksPackagePath, libPackagePath];
const packageBoundaryOperations = state.operations.filter(operation => (
boundaryPaths.includes(operation.destinationPath)
));
assert.deepStrictEqual(
packageBoundaryOperations.map(operation => operation.destinationPath).sort(),
[...boundaryPaths].sort()
);
assert.ok(packageBoundaryOperations.every(operation => operation.moduleId === 'hooks-runtime'));
assert.ok(packageBoundaryOperations.every(operation => (
/^[a-f0-9]{64}$/i.test(operation.contentSha256)
)));
assert.ok(!state.operations.some(operation => operation.destinationPath === userPackagePath));
assert.ok(!state.operations.some(operation => operation.destinationPath === scriptsPackagePath));
} finally {
cleanup(homeDir);
cleanup(projectDir);
}
})) passed++; else failed++;
if (test('preserves existing settings.json while disabling Claude co-author attribution', () => {
const homeDir = createTempDir('install-apply-home-');
const projectDir = createTempDir('install-apply-project-');
+53
View File
@@ -132,6 +132,59 @@ function runTests() {
}
})) passed++; else failed++;
if (test('uninstalls the project hook module boundary and preserves user Claude package data', () => {
const homeDir = createTempDir('uninstall-claude-project-esm-home-');
const projectRoot = createTempDir('uninstall-claude-project-esm-');
const claudeRoot = path.join(projectRoot, '.claude');
const userPackagePath = path.join(claudeRoot, 'package.json');
const scriptsPackagePath = path.join(claudeRoot, 'scripts', 'package.json');
const hooksPackagePath = path.join(claudeRoot, 'scripts', 'hooks', 'package.json');
const libPackagePath = path.join(claudeRoot, 'scripts', 'lib', 'package.json');
const statePath = path.join(claudeRoot, 'ecc', 'install-state.json');
const userPackage = '{"name":"user-claude-config","type":"module"}\n';
const userScriptsPackage = '{"name":"user-claude-scripts","type":"module"}\n';
try {
fs.writeFileSync(path.join(projectRoot, 'package.json'), '{"type":"module"}\n');
fs.mkdirSync(path.dirname(scriptsPackagePath), { recursive: true });
fs.writeFileSync(userPackagePath, userPackage);
fs.writeFileSync(scriptsPackagePath, userScriptsPackage);
execFileSync(
'node',
[INSTALL_SCRIPT, '--target', 'claude-project', '--profile', 'core', '--enable-hooks'],
{
cwd: projectRoot,
env: {
...process.env,
HOME: homeDir,
USERPROFILE: homeDir,
},
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'pipe'],
timeout: CLI_TIMEOUT_MS,
}
);
assert.deepStrictEqual(JSON.parse(fs.readFileSync(hooksPackagePath, 'utf8')), { type: 'commonjs' });
assert.deepStrictEqual(JSON.parse(fs.readFileSync(libPackagePath, 'utf8')), { type: 'commonjs' });
assert.strictEqual(fs.readFileSync(scriptsPackagePath, 'utf8'), userScriptsPackage);
const uninstallResult = run(['--target', 'claude-project'], {
cwd: projectRoot,
homeDir,
});
assert.strictEqual(uninstallResult.code, 0, uninstallResult.stderr);
assert.strictEqual(fs.readFileSync(userPackagePath, 'utf8'), userPackage);
assert.strictEqual(fs.readFileSync(scriptsPackagePath, 'utf8'), userScriptsPackage);
assert.ok(!fs.existsSync(hooksPackagePath));
assert.ok(!fs.existsSync(libPackagePath));
assert.ok(!fs.existsSync(statePath));
} finally {
cleanup(homeDir);
cleanup(projectRoot);
}
})) passed++; else failed++;
if (test('reverses non-copy operations and keeps unrelated files', () => {
const homeDir = createTempDir('uninstall-home-');
const projectRoot = createTempDir('uninstall-project-');