fix(config-protection): protect ignore files and current config spellings

The hook blocks edits to linter/formatter configs so an agent fixes the code
instead of weakening the checks. It did not cover the cheapest way to weaken
them: adding one path to an ignore file. Measured against the hook, with the
file already on disk:

  .eslintignore        -> allow
  .prettierignore      -> allow
  .stylelintignore     -> allow
  .markdownlintignore  -> allow

Nor did it cover the current config names — only the legacy `.stylelintrc*`
and `.markdownlint.json` spellings were listed, so a project using the
documented `stylelint.config.js`, `.markdownlint.jsonc` or markdownlint-cli2
had no protection at all.

First-time creation stays allowed by the existing existence check, so
scaffolding a fresh ignore file is unaffected.
This commit is contained in:
Nguyen Thanh Dat
2026-08-24 10:31:39 +07:00
parent d8409a4b08
commit 9a44131a1a
2 changed files with 136 additions and 1 deletions
+27 -1
View File
@@ -57,9 +57,35 @@ const PROTECTED_FILES = new Set([
'.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',
'.markdownlint.json',
'.markdownlint.jsonc',
'.markdownlint.yaml',
'.markdownlintrc'
'.markdownlint.yml',
'.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'
]);
function parseInput(inputOrRaw) {
+109
View File
@@ -323,6 +323,115 @@ function runTests() {
passed++;
else failed++;
if (
test('blocks edits to an existing linter ignore file', () => {
// Adding one path to .eslintignore silences a failing file without
// touching the code or the config — the exact move this hook exists to
// stop, and it was allowed. Measured before the fix: .eslintignore,
// .prettierignore, .stylelintignore and .markdownlintignore all
// returned exit 0.
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-config-protect-'));
try {
for (const name of [
'.eslintignore',
'.prettierignore',
'.stylelintignore',
'.markdownlintignore'
]) {
const absPath = path.join(tmpDir, name);
fs.writeFileSync(absPath, 'dist/\n');
const result = runHook({
tool_name: 'Edit',
tool_input: { file_path: absPath, content: 'dist/\nsrc/failing-file.ts\n' }
});
assert.strictEqual(result.code, 2, `Expected exit 2 for ${name}, got ${result.code}`);
assert.ok(
result.stderr.includes(`BLOCKED: Modifying ${name} is not allowed.`),
`Expected block message for ${name}, got: ${result.stderr}`
);
}
} finally {
try {
fs.rmSync(tmpDir, { recursive: true, force: true });
} catch {
// best-effort cleanup
}
}
})
)
passed++;
else failed++;
if (
test('blocks the current stylelint and markdownlint config spellings', () => {
// Only the legacy `.stylelintrc*` / `.markdownlint.json` names were
// listed, so a project on the documented `stylelint.config.js` or
// markdownlint-cli2 had no protection at all.
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-config-protect-'));
try {
for (const name of [
'stylelint.config.js',
'stylelint.config.mjs',
'.stylelintrc.js',
'.markdownlint.jsonc',
'.markdownlint-cli2.jsonc'
]) {
const absPath = path.join(tmpDir, name);
fs.writeFileSync(absPath, '{}');
const result = runHook({
tool_name: 'Edit',
tool_input: { file_path: absPath, content: '{"rules": {}}' }
});
assert.strictEqual(result.code, 2, `Expected exit 2 for ${name}, got ${result.code}`);
}
} finally {
try {
fs.rmSync(tmpDir, { recursive: true, force: true });
} catch {
// best-effort cleanup
}
}
})
)
passed++;
else failed++;
if (
test('a first-time ignore file and a lookalike name are still allowed', () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-config-protect-'));
try {
// Scaffolding a brand-new ignore file is the same legitimate bootstrap
// path the hook already allows for configs.
const fresh = runHook({
tool_name: 'Write',
tool_input: { file_path: path.join(tmpDir, '.prettierignore'), content: 'dist/\n' }
});
assert.strictEqual(fresh.code, 0, `Expected exit 0 for a new ignore file, got ${fresh.code}`);
// A file that merely looks like one must not be swept up.
const lookalike = path.join(tmpDir, '.eslintignore.bak');
fs.writeFileSync(lookalike, 'dist/\n');
const result = runHook({
tool_name: 'Edit',
tool_input: { file_path: lookalike, content: 'dist/\nsrc/\n' }
});
assert.strictEqual(result.code, 0, `Expected exit 0 for ${path.basename(lookalike)}`);
} 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()}`);