Files
ECC/scripts/hooks/config-protection.js
T
Emad DoughanandGitHub e7b3ba07bb fix(config-protection): match protected filenames case-insensitively (#2543)
On a case-insensitive filesystem (macOS APFS/HFS+, Windows NTFS) a write to
`.ESLINTRC.JS` lands on the exact same inode as `.eslintrc.js`, but the guard
looked the basename up in PROTECTED_FILES with a case-sensitive `Set.has`.
Every entry in that Set is lowercase, so any case-variant path missed the
branch entirely and returned exit 0 — a single Write silently overwrote a
live config while the hook reported success.

Reproduced on macOS APFS: `.eslintrc.js` and `.ESLINTRC.JS` share one inode,
yet the hook returned exit 2 for the former and exit 0 for the latter, and the
uppercase write replaced the real config's contents.

This is a one-step bypass of the whole guard and needs no shell access, unlike
the known delete-then-recreate route.

Fix: also test `basename.toLowerCase()`. All 32 PROTECTED_FILES entries are
already lowercase, so the fallback is exact. On a genuinely case-sensitive
filesystem this costs at most a false positive on a distinct file whose name
differs from a protected one by case alone.

Behaviour deliberately unchanged: first-time creation is still allowed (the
bootstrap affordance), non-config paths still pass through, and the existing
lstat/ENOENT fail-closed semantics are untouched.

Test: adds a case-variant case that asserts exit 2. It guards itself with an
inode comparison and skips on case-sensitive filesystems rather than asserting
something untrue there. Verified in both directions — it FAILS against the
unpatched hook (`Got 0; 0 !== 2`) and passes with the fix. Suite: 9/9.
2026-07-22 12:17:04 -04:00

177 lines
5.4 KiB
JavaScript

#!/usr/bin/env node
/**
* Config Protection Hook
*
* Blocks modifications to linter/formatter config files.
* Agents frequently modify these to make checks pass instead of fixing
* the actual code. This hook steers the agent back to fixing the source.
*
* Exit codes:
* 0 = allow (not a config file, or first-time creation of one)
* 2 = block (existing config file modification attempted)
*/
'use strict';
const fs = require('fs');
const path = require('path');
const MAX_STDIN = 1024 * 1024;
let raw = '';
const PROTECTED_FILES = new Set([
// ESLint (legacy + v9 flat config, JS/TS/MJS/CJS)
'.eslintrc',
'.eslintrc.js',
'.eslintrc.cjs',
'.eslintrc.json',
'.eslintrc.yml',
'.eslintrc.yaml',
'eslint.config.js',
'eslint.config.mjs',
'eslint.config.cjs',
'eslint.config.ts',
'eslint.config.mts',
'eslint.config.cts',
// Prettier (all config variants including ESM)
'.prettierrc',
'.prettierrc.js',
'.prettierrc.cjs',
'.prettierrc.json',
'.prettierrc.yml',
'.prettierrc.yaml',
'prettier.config.js',
'prettier.config.cjs',
'prettier.config.mjs',
// Biome
'biome.json',
'biome.jsonc',
// Ruff (Python)
'.ruff.toml',
'ruff.toml',
// Note: pyproject.toml is intentionally NOT included here because it
// contains project metadata alongside linter config. Blocking all edits
// to pyproject.toml would prevent legitimate dependency changes.
// Shell / Style / Markdown
'.shellcheckrc',
'.stylelintrc',
'.stylelintrc.json',
'.stylelintrc.yml',
'.markdownlint.json',
'.markdownlint.yaml',
'.markdownlintrc'
]);
function parseInput(inputOrRaw) {
if (typeof inputOrRaw === 'string') {
try {
return inputOrRaw.trim() ? JSON.parse(inputOrRaw) : {};
} catch {
return {};
}
}
return inputOrRaw && typeof inputOrRaw === 'object' ? inputOrRaw : {};
}
/**
* Exportable run() for in-process execution via run-with-flags.js.
* Avoids the ~50-100ms spawnSync overhead when available.
*/
function run(inputOrRaw, options = {}) {
if (options.truncated) {
return {
exitCode: 2,
stderr:
`BLOCKED: Hook input exceeded ${options.maxStdin || MAX_STDIN} bytes. ` +
'Refusing to bypass config-protection on a truncated payload. ' +
'Retry with a smaller edit or disable the config-protection hook temporarily.'
};
}
const input = parseInput(inputOrRaw);
const filePath = input?.tool_input?.file_path || input?.tool_input?.file || '';
if (!filePath) return { exitCode: 0 };
const basename = path.basename(filePath);
// 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
// path (e.g. scaffolding ESLint into a fresh repo).
//
// Fail closed on any stat error other than ENOENT. Use lstatSync so a
// symlink at the protected path is treated as present even if its target
// is missing — a dangling symlink at e.g. .eslintrc.js still represents
// an existing config entry that an agent should not silently replace.
// fs.existsSync would swallow EACCES/EPERM as false; lstatSync exposes
// the error code so we can treat only genuine "path not found" (ENOENT)
// as absent.
let exists = true;
try {
fs.lstatSync(filePath);
// lstat succeeded — something (file, dir, or symlink) exists here.
} catch (err) {
if (err && err.code === 'ENOENT') {
exists = false;
}
// Any other error (EACCES, EPERM, ELOOP, etc.) leaves exists=true
// so the guard is never silently weakened.
}
if (!exists) {
return { exitCode: 0 };
}
return {
exitCode: 2,
stderr:
`BLOCKED: Modifying ${basename} is not allowed. ` +
'Fix the source code to satisfy linter/formatter rules instead of ' +
'weakening the config. If this is a legitimate config change, ' +
'disable the config-protection hook temporarily.'
};
}
return { exitCode: 0 };
}
module.exports = { run };
// Stdin fallback for spawnSync execution
let truncated = /^(1|true|yes)$/i.test(String(process.env.ECC_HOOK_INPUT_TRUNCATED || ''));
process.stdin.setEncoding('utf8');
process.stdin.on('data', chunk => {
if (raw.length < MAX_STDIN) {
const remaining = MAX_STDIN - raw.length;
raw += chunk.substring(0, remaining);
if (chunk.length > remaining) truncated = true;
} else {
truncated = true;
}
});
process.stdin.on('end', () => {
const result = run(raw, {
truncated,
maxStdin: Number(process.env.ECC_HOOK_INPUT_MAX_BYTES) || MAX_STDIN
});
if (result.stderr) {
process.stderr.write(result.stderr + '\n');
}
if (result.exitCode === 2) {
process.exit(2);
}
process.stdout.write(raw);
});