fix(hooks): address greptile review — use statSync for true fail-closed

Greptile P1 on PR #1898: fs.existsSync internally catches all errors and
returns false, so the previous try/catch around it was dead code and the
stated "fail-closed on EACCES" semantics weren't actually delivered. A
file under a directory with no execute permission would read as absent
and bypass the guard.

Swap to fs.statSync with explicit ENOENT detection. Only ENOENT flips
exists to false; every other error code (EACCES, EPERM, ELOOP, etc.)
leaves exists=true so the modification guard is never silently weakened.

Add a new test "allows first-time creation when the parent directory
does not exist yet" that exercises the ENOENT path via a non-existent
parent dir — pins the happy path into the regression suite.
This commit is contained in:
gaurav0107
2026-05-15 00:57:03 +05:30
parent faa51fba11
commit a8fe098c88
2 changed files with 195 additions and 132 deletions

View File

@@ -59,7 +59,7 @@ const PROTECTED_FILES = new Set([
'.stylelintrc.yml',
'.markdownlint.json',
'.markdownlint.yaml',
'.markdownlintrc',
'.markdownlintrc'
]);
function parseInput(inputOrRaw) {
@@ -99,13 +99,22 @@ function run(inputOrRaw, options = {}) {
// 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;
//
// Fail closed on any stat error other than ENOENT. fs.existsSync would
// swallow EACCES/EPERM and return false, which would let an agent
// overwrite a file whose parent directory we cannot traverse. statSync
// exposes the error code explicitly so we can treat only genuine
// "file not found" as absent.
let exists = true;
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;
fs.statSync(filePath);
// stat succeeded — file exists.
} 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) {
@@ -118,7 +127,7 @@ function run(inputOrRaw, options = {}) {
`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.',
'disable the config-protection hook temporarily.'
};
}
@@ -143,7 +152,7 @@ process.stdin.on('data', chunk => {
process.stdin.on('end', () => {
const result = run(raw, {
truncated,
maxStdin: Number(process.env.ECC_HOOK_INPUT_MAX_BYTES) || MAX_STDIN,
maxStdin: Number(process.env.ECC_HOOK_INPUT_MAX_BYTES) || MAX_STDIN
});
if (result.stderr) {