Files
everything-claude-code/scripts/hooks/config-protection.js
gaurav0107 7145ca9dfe fix(hooks): address coderabbit review — use lstatSync for symlink paths
CodeRabbit major on PR #1898: fs.statSync follows symlinks, so a dangling
protected symlink (e.g. .eslintrc.js pointing at a missing target) would
throw ENOENT and be treated as absent — letting an agent "replace" the
symlink and bypass the protection.

Swap statSync for lstatSync. lstat reports the link node itself regardless
of whether its target exists, so protected entries that happen to be
symlinks stay blocked. ENOENT handling is unchanged: only a genuinely
missing path (no link, no file, no directory) counts as absent.

Add a regression test that creates a dangling symlink at .eslintrc.js and
verifies the hook still blocks Write. Skips cleanly on platforms/sandboxes
that disallow symlink creation (EPERM/EACCES).
2026-05-15 01:09:43 +05:30

170 lines
4.8 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);
if (PROTECTED_FILES.has(basename)) {
// 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);
});