mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-06-11 02:33:10 +08:00
The config-protection hook blocks Write/Edit on any basename in the PROTECTED_FILES set, regardless of whether the file already exists. The hook's stated purpose is to prevent agents from softening rules in an existing config — but the same code path also blocks the legitimate bootstrap case of scaffolding a linter config into a project that has none. Add an fs.existsSync check inside run(): when the basename matches a protected entry and the file does not yet exist on disk, exit 0 and let the Write proceed. Keep the exit-2 block for all modifications to existing files. Stat errors (EACCES, etc.) fail closed — we treat the path as existing so the guard is never silently weakened. Update the existing "blocks protected config file edits" test to use a real temp file so the BLOCK path is still exercised, and add two new tests covering: - first-time creation of eslint.config.mjs is allowed (exit 0, raw passthrough, no stderr) - Edit against an existing .eslintrc.js is still blocked (exit 2, no stdout, BLOCKED message in stderr) Fixes #1873
159 lines
4.2 KiB
JavaScript
159 lines
4.2 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).
|
|
let exists = false;
|
|
try {
|
|
exists = fs.existsSync(filePath);
|
|
} catch {
|
|
// Be conservative: on stat errors (EACCES, etc.) treat as existing
|
|
// so we never silently weaken the guard.
|
|
exists = true;
|
|
}
|
|
|
|
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);
|
|
});
|