fix(hooks): allow first-time creation of protected config files

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
This commit is contained in:
gaurav0107
2026-05-15 00:30:23 +05:30
parent 4423f10cfb
commit faa51fba11
2 changed files with 97 additions and 13 deletions

View File

@@ -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:

View File

@@ -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');