#!/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's discovered filenames. Custom --config-path/extends targets need // reference context; an arbitrary biome.* basename is not sufficient. 'biome.json', 'biome.jsonc', '.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', '.stylelintrc.yaml', '.stylelintrc.js', '.stylelintrc.cjs', '.stylelintrc.mjs', // Stylelint's current spelling; only the legacy `.stylelintrc*` forms were // listed, so a project using the documented `stylelint.config.js` had no // protection at all. 'stylelint.config.js', 'stylelint.config.cjs', 'stylelint.config.mjs', 'stylelint.config.ts', 'stylelint.config.mts', 'stylelint.config.cts', '.markdownlint.json', '.markdownlint.jsonc', '.markdownlint.yaml', '.markdownlint.yml', '.markdownlint.cjs', '.markdownlint.mjs', '.markdownlintrc', // markdownlint-cli2 reads its own config names, not `.markdownlint.*`. '.markdownlint-cli2.jsonc', '.markdownlint-cli2.yaml', '.markdownlint-cli2.cjs', '.markdownlint-cli2.mjs', // Ignore files are the cheapest way to make a check pass without touching // the code OR the config: adding one path to .eslintignore silences the // failing file outright. Blocking the config while leaving its ignore list // open left the hook's whole purpose one line away from being defeated. // First-time creation stays allowed by the same existence check below. '.eslintignore', '.prettierignore', '.stylelintignore', '.markdownlintignore' ]); /** * Exact basenames only catch a tool's canonical entry point. Real repos split * flat config across files: a shared `eslint.config.base.mjs` holding the * ignore list and rule severities, imported by per-workspace * `eslint.config.mjs` files. That is the common monorepo shape, and matching * basenames alone protected the leaves while leaving the trunk -- the file that * actually carries the rules -- freely editable. * * These patterns cover `.config..` and * `.rc..` for the linters and formatters listed above. * They are case-insensitive for the same reason the Set lookup above is. * * Deliberately NOT matched: build and test tooling -- `vite.config.ts`, * `vitest.config.ts`, `jest.config.js`, `playwright.config.ts`, * `tsconfig.json`. This hook exists to stop a LINTER config being weakened in * place of fixing the code; editing a bundler or test-runner config is * ordinary work, and sweeping those in would make the hook obstructive. */ const PROTECTED_PATTERNS = [ // eslint.config.base.mjs, prettier.config.shared.cjs, stylelint.config.local.js ... /^(eslint|prettier|stylelint|commitlint|oxlint)\.config(\.[A-Za-z0-9_-]+)*\.(js|mjs|cjs|ts|mts|cts)$/i, // .eslintrc.base.json, .prettierrc.shared.yml ... /^\.(eslintrc|prettierrc|stylelintrc|markdownlintrc)(\.[A-Za-z0-9_-]+)*\.(js|cjs|mjs|json|jsonc|yml|yaml|toml)$/i, ]; function isProtectedName(basename) { const lower = basename.toLowerCase(); return PROTECTED_FILES.has(basename) || PROTECTED_FILES.has(lower) || PROTECTED_PATTERNS.some((re) => re.test(basename)); } 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); // Match case-insensitively. Every PROTECTED_FILES entry is lowercase, and on // case-insensitive filesystems (macOS APFS/HFS+, Windows NTFS) a write to // `.ESLINTRC.JS` lands on the very same inode as `.eslintrc.js`. A // case-sensitive Set lookup therefore let a single case-variant Write // silently overwrite the real config while the guard returned exit 0. // On genuinely case-sensitive filesystems this only costs a false positive // on a distinct file that differs from a protected name by case alone. if (isProtectedName(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); });