From faa51fba116237f02085f7fe2a549dbe46c95ad9 Mon Sep 17 00:00:00 2001 From: gaurav0107 Date: Fri, 15 May 2026 00:30:23 +0530 Subject: [PATCH] fix(hooks): allow first-time creation of protected config files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- scripts/hooks/config-protection.js | 22 ++++++- tests/hooks/config-protection.test.js | 88 +++++++++++++++++++++++---- 2 files changed, 97 insertions(+), 13 deletions(-) diff --git a/scripts/hooks/config-protection.js b/scripts/hooks/config-protection.js index 8592542e..83a7150a 100644 --- a/scripts/hooks/config-protection.js +++ b/scripts/hooks/config-protection.js @@ -7,12 +7,13 @@ * 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) + * 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; @@ -94,6 +95,23 @@ function run(inputOrRaw, options = {}) { 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: diff --git a/tests/hooks/config-protection.test.js b/tests/hooks/config-protection.test.js index 8f01b4b7..86eabf36 100644 --- a/tests/hooks/config-protection.test.js +++ b/tests/hooks/config-protection.test.js @@ -4,6 +4,7 @@ const assert = require('assert'); const fs = require('fs'); +const os = require('os'); const path = require('path'); const { spawnSync } = require('child_process'); @@ -71,18 +72,30 @@ function runTests() { let failed = 0; if (test('blocks protected config file edits through run-with-flags', () => { - const input = { - tool_name: 'Write', - tool_input: { - file_path: '.eslintrc.js', - content: 'module.exports = {};' - } - }; + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-config-protect-')); + try { + const absPath = path.join(tmpDir, '.eslintrc.js'); + fs.writeFileSync(absPath, 'module.exports = {};'); - const result = runHook(input); - assert.strictEqual(result.code, 2, 'Expected protected config edit to be blocked'); - assert.strictEqual(result.stdout, '', 'Blocked hook should not echo raw input'); - assert.ok(result.stderr.includes('BLOCKED: Modifying .eslintrc.js is not allowed.'), `Expected block message, got: ${result.stderr}`); + const input = { + tool_name: 'Write', + tool_input: { + file_path: absPath, + content: 'module.exports = {};' + } + }; + + const result = runHook(input); + assert.strictEqual(result.code, 2, 'Expected protected config edit to be blocked'); + assert.strictEqual(result.stdout, '', 'Blocked hook should not echo raw input'); + assert.ok(result.stderr.includes('BLOCKED: Modifying .eslintrc.js is not allowed.'), `Expected block message, got: ${result.stderr}`); + } finally { + try { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } catch { + // best-effort cleanup + } + } })) passed++; else failed++; if (test('passes through safe file edits unchanged', () => { @@ -117,6 +130,59 @@ function runTests() { assert.ok(result.stderr.includes('truncated payload'), `Expected truncated payload warning, got: ${result.stderr}`); })) passed++; else failed++; + if (test('allows first-time creation of a protected config file', () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-config-protect-')); + try { + const absPath = path.join(tmpDir, 'eslint.config.mjs'); + const input = { + tool_name: 'Write', + tool_input: { + file_path: absPath, + content: 'export default [];' + } + }; + + const rawInput = JSON.stringify(input); + const result = runHook(input); + assert.strictEqual(result.code, 0, `Expected exit 0 for first-time creation, got ${result.code}; stderr: ${result.stderr}`); + assert.strictEqual(result.stdout, rawInput, 'Expected raw passthrough when creation is allowed'); + assert.strictEqual(result.stderr, '', `Expected no stderr for first-time creation, got: ${result.stderr}`); + } finally { + try { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } catch { + // best-effort cleanup + } + } + })) passed++; else failed++; + + if (test('still blocks writes to an existing protected config file', () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-config-protect-')); + try { + const absPath = path.join(tmpDir, '.eslintrc.js'); + fs.writeFileSync(absPath, 'module.exports = { rules: {} };'); + + const input = { + tool_name: 'Edit', + tool_input: { + file_path: absPath, + content: 'module.exports = { rules: { "no-console": "off" } };' + } + }; + + const result = runHook(input); + assert.strictEqual(result.code, 2, 'Expected exit 2 when modifying an existing protected config'); + assert.strictEqual(result.stdout, '', 'Blocked hook should not echo raw input'); + assert.ok(result.stderr.includes('BLOCKED: Modifying .eslintrc.js is not allowed.'), `Expected block message, got: ${result.stderr}`); + } finally { + try { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } catch { + // best-effort cleanup + } + } + })) passed++; else failed++; + if (test('legacy hooks do not echo raw input when they fail without stdout', () => { const pluginRoot = path.join(__dirname, '..', `tmp-runner-plugin-${Date.now()}`); const scriptDir = path.join(pluginRoot, 'scripts', 'hooks');