CodeRabbit major on PR #1898: fs.statSync follows symlinks, so a dangling
protected symlink (e.g. .eslintrc.js pointing at a missing target) would
throw ENOENT and be treated as absent — letting an agent "replace" the
symlink and bypass the protection.
Swap statSync for lstatSync. lstat reports the link node itself regardless
of whether its target exists, so protected entries that happen to be
symlinks stay blocked. ENOENT handling is unchanged: only a genuinely
missing path (no link, no file, no directory) counts as absent.
Add a regression test that creates a dangling symlink at .eslintrc.js and
verifies the hook still blocks Write. Skips cleanly on platforms/sandboxes
that disallow symlink creation (EPERM/EACCES).
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.
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
* feat(hooks): add config protection hook to block linter config manipulation
Agents frequently modify linter/formatter configs (.eslintrc, biome.json,
.prettierrc, .ruff.toml, etc.) to make checks pass instead of fixing
the actual code.
This PreToolUse hook intercepts Write/Edit/MultiEdit calls targeting
known config files and blocks them with a steering message that directs
the agent to fix the source code instead.
Covers: ESLint, Prettier, Biome, Ruff, ShellCheck, Stylelint, and
Markdownlint configs.
Fixes#733
* Address review: fix dead code, add missing configs, export run()
- Removed pyproject.toml from PROTECTED_FILES (was dead code since
it was also in PARTIAL_CONFIG_FILES). Added comment explaining why
it's intentionally excluded.
- Removed PARTIAL_CONFIG_FILES entirely (no longer needed).
- Added missing ESLint v9 TypeScript flat configs: eslint.config.ts,
eslint.config.mts, eslint.config.cts
- Added missing Prettier ESM config: prettier.config.mjs
- Exported run() function for in-process execution via run-with-flags,
avoiding the spawnSync overhead (~50-100ms per call).
* Handle stdin truncation gracefully, log warning instead of fail-open
If stdin exceeds 1MB, the JSON would be malformed and the catch
block would silently pass through. Now we detect truncation and
log a warning. The in-process run() path is not affected.