mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-03-30 13:43:26 +08:00
feat(hooks): add config protection hook to block linter config manipulation (#758)
* feat(hooks): add config protection hook to block linter config manipulation Agents frequently modify linter/formatter configs (.eslintrc, biome.json, .prettierrc, .ruff.toml, etc.) to make checks pass instead of fixing the actual code. This PreToolUse hook intercepts Write/Edit/MultiEdit calls targeting known config files and blocks them with a steering message that directs the agent to fix the source code instead. Covers: ESLint, Prettier, Biome, Ruff, ShellCheck, Stylelint, and Markdownlint configs. Fixes #733 * Address review: fix dead code, add missing configs, export run() - Removed pyproject.toml from PROTECTED_FILES (was dead code since it was also in PARTIAL_CONFIG_FILES). Added comment explaining why it's intentionally excluded. - Removed PARTIAL_CONFIG_FILES entirely (no longer needed). - Added missing ESLint v9 TypeScript flat configs: eslint.config.ts, eslint.config.mts, eslint.config.cts - Added missing Prettier ESM config: prettier.config.mjs - Exported run() function for in-process execution via run-with-flags, avoiding the spawnSync overhead (~50-100ms per call). * Handle stdin truncation gracefully, log warning instead of fail-open If stdin exceeds 1MB, the JSON would be malformed and the catch block would silently pass through. Now we detect truncation and log a warning. The in-process run() path is not affected.
This commit is contained in:
committed by
GitHub
parent
401dca07d0
commit
fdb10ba116
@@ -96,6 +96,17 @@
|
|||||||
],
|
],
|
||||||
"description": "Capture governance events (secrets, policy violations, approval requests). Enable with ECC_GOVERNANCE_CAPTURE=1"
|
"description": "Capture governance events (secrets, policy violations, approval requests). Enable with ECC_GOVERNANCE_CAPTURE=1"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"matcher": "Write|Edit|MultiEdit",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hooks/run-with-flags.js\" \"pre:config-protection\" \"scripts/hooks/config-protection.js\" \"standard,strict\"",
|
||||||
|
"timeout": 5
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Block modifications to linter/formatter config files. Steers agent to fix code instead of weakening configs."
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"matcher": "*",
|
"matcher": "*",
|
||||||
"hooks": [
|
"hooks": [
|
||||||
|
|||||||
125
scripts/hooks/config-protection.js
Normal file
125
scripts/hooks/config-protection.js
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
#!/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)
|
||||||
|
* 2 = block (config file modification attempted)
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
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',
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exportable run() for in-process execution via run-with-flags.js.
|
||||||
|
* Avoids the ~50-100ms spawnSync overhead when available.
|
||||||
|
*/
|
||||||
|
function run(input) {
|
||||||
|
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)) {
|
||||||
|
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 = false;
|
||||||
|
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', () => {
|
||||||
|
// If stdin was truncated, the JSON is likely malformed. Fail open but
|
||||||
|
// log a warning so the issue is visible. The run() path (used by
|
||||||
|
// run-with-flags.js in-process) is not affected by this.
|
||||||
|
if (truncated) {
|
||||||
|
process.stderr.write('[config-protection] Warning: stdin exceeded 1MB, skipping check\n');
|
||||||
|
process.stdout.write(raw);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const input = raw.trim() ? JSON.parse(raw) : {};
|
||||||
|
const result = run(input);
|
||||||
|
|
||||||
|
if (result.exitCode === 2) {
|
||||||
|
process.stderr.write(result.stderr + '\n');
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Keep hook non-blocking on parse errors.
|
||||||
|
}
|
||||||
|
|
||||||
|
process.stdout.write(raw);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user