mirror of
https://github.com/affaan-m/ECC.git
synced 2026-08-17 21:15:40 +02:00
On a case-insensitive filesystem (macOS APFS/HFS+, Windows NTFS) a write to `.ESLINTRC.JS` lands on the exact same inode as `.eslintrc.js`, but the guard looked the basename up in PROTECTED_FILES with a case-sensitive `Set.has`. Every entry in that Set is lowercase, so any case-variant path missed the branch entirely and returned exit 0 — a single Write silently overwrote a live config while the hook reported success. Reproduced on macOS APFS: `.eslintrc.js` and `.ESLINTRC.JS` share one inode, yet the hook returned exit 2 for the former and exit 0 for the latter, and the uppercase write replaced the real config's contents. This is a one-step bypass of the whole guard and needs no shell access, unlike the known delete-then-recreate route. Fix: also test `basename.toLowerCase()`. All 32 PROTECTED_FILES entries are already lowercase, so the fallback is exact. On a genuinely case-sensitive filesystem this costs at most a false positive on a distinct file whose name differs from a protected one by case alone. Behaviour deliberately unchanged: first-time creation is still allowed (the bootstrap affordance), non-config paths still pass through, and the existing lstat/ENOENT fail-closed semantics are untouched. Test: adds a case-variant case that asserts exit 2. It guards itself with an inode comparison and skips on case-sensitive filesystems rather than asserting something untrue there. Verified in both directions — it FAILS against the unpatched hook (`Got 0; 0 !== 2`) and passes with the fix. Suite: 9/9.
177 lines
5.4 KiB
JavaScript
177 lines
5.4 KiB
JavaScript
#!/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
|
|
'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'
|
|
]);
|
|
|
|
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 (PROTECTED_FILES.has(basename) || PROTECTED_FILES.has(basename.toLowerCase())) {
|
|
// 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);
|
|
});
|