diff --git a/scripts/hooks/config-protection.js b/scripts/hooks/config-protection.js index 625da3b71..2da5358c2 100644 --- a/scripts/hooks/config-protection.js +++ b/scripts/hooks/config-protection.js @@ -94,7 +94,14 @@ function run(inputOrRaw, options = {}) { if (!filePath) return { exitCode: 0 }; const basename = path.basename(filePath); - if (PROTECTED_FILES.has(basename)) { + // Match case-insensitively. Every PROTECTED_FILES entry is lowercase, and on + // case-insensitive filesystems (macOS APFS/HFS+, Windows NTFS) a write to + // `.ESLINTRC.JS` lands on the very same inode as `.eslintrc.js`. A + // case-sensitive Set lookup therefore let a single case-variant Write + // silently overwrite the real config while the guard returned exit 0. + // On genuinely case-sensitive filesystems this only costs a false positive + // on a distinct file that differs from a protected name by case alone. + if (PROTECTED_FILES.has(basename) || PROTECTED_FILES.has(basename.toLowerCase())) { // Allow first-time creation — there's no existing config to weaken. // The hook's purpose is blocking modifications; writing a brand-new // config file in a project that has none is a legitimate bootstrap diff --git a/tests/hooks/config-protection.test.js b/tests/hooks/config-protection.test.js index ec57c1375..e383753ec 100644 --- a/tests/hooks/config-protection.test.js +++ b/tests/hooks/config-protection.test.js @@ -234,10 +234,52 @@ function runTests() { const result = runHook(input); assert.strictEqual(result.code, 2, `Expected exit 2 for dangling symlink, got ${result.code}; stderr: ${result.stderr}`); assert.strictEqual(result.stdout, '', 'Blocked hook should not echo raw input'); - assert.ok( - result.stderr.includes('BLOCKED: Modifying .eslintrc.js is not allowed.'), - `Expected block message, got: ${result.stderr}` - ); + assert.ok(result.stderr.includes('BLOCKED: Modifying .eslintrc.js is not allowed.'), `Expected block message, got: ${result.stderr}`); + } finally { + try { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } catch { + // best-effort cleanup + } + } + }) + ) + passed++; + else failed++; + + if ( + test('blocks case-variant writes that resolve to an existing protected config', () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-config-protect-')); + try { + const realPath = path.join(tmpDir, '.eslintrc.js'); + const variantPath = path.join(tmpDir, '.ESLINTRC.JS'); + fs.writeFileSync(realPath, 'module.exports = { rules: { "no-explicit-any": "error" } };'); + + // Only meaningful on a case-insensitive filesystem (macOS APFS/HFS+, + // Windows NTFS), where the uppercase path is the SAME inode. On a + // case-sensitive filesystem the variant is a genuinely different file + // and the write is harmless, so skip rather than assert. + let sameFile = false; + try { + sameFile = fs.lstatSync(variantPath).ino === fs.lstatSync(realPath).ino; + } catch { + sameFile = false; + } + if (!sameFile) { + console.log(' (skipped: case-sensitive filesystem)'); + return; + } + + const result = runHook({ + tool_name: 'Write', + tool_input: { + file_path: variantPath, + content: 'module.exports = { rules: {} }; // WEAKENED' + } + }); + + assert.strictEqual(result.code, 2, `Case-variant write must be blocked: it overwrites ${path.basename(realPath)} on this filesystem. Got ${result.code}; stderr: ${result.stderr}`); + assert.strictEqual(result.stdout, '', 'Blocked hook should not echo raw input'); } finally { try { fs.rmSync(tmpDir, { recursive: true, force: true });