From fdb10ba11624cb17f59aa2fbe14363039afe3ca8 Mon Sep 17 00:00:00 2001 From: Charlie Tonneslan Date: Sun, 22 Mar 2026 18:39:54 -0400 Subject: [PATCH] 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. --- hooks/hooks.json | 11 +++ scripts/hooks/config-protection.js | 125 +++++++++++++++++++++++++++++ 2 files changed, 136 insertions(+) create mode 100644 scripts/hooks/config-protection.js diff --git a/hooks/hooks.json b/hooks/hooks.json index 2efd0bdb..b44e6bf0 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -96,6 +96,17 @@ ], "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": "*", "hooks": [ diff --git a/scripts/hooks/config-protection.js b/scripts/hooks/config-protection.js new file mode 100644 index 00000000..f5fbcf4a --- /dev/null +++ b/scripts/hooks/config-protection.js @@ -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); +});