mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-06-10 18:23:12 +08:00
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).
170 lines
4.8 KiB
JavaScript
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);
|
|
});
|