Preserve the original contributor histories and apply the exact independently reviewed repair.
Source-Parent: 74023e5689
Review-Manifest-SHA256: 208a6350119857df7c1e5b83a46cdba0b7f7399f58ed60167397920c7a21b454
Stylelint resolves stylelint.config.{ts,mts,cts} through cosmiconfig and
markdownlint reads .markdownlint.{cjs,mjs}; both were still editable while the
hook was active.
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.
`PROTECTED_FILES` matches exact basenames, so it only ever guarded a tool's
canonical entry point. Real repos split flat config across files — a shared
`eslint.config.base.mjs` holding the ignore list and rule severities, imported
by per-workspace `eslint.config.mjs` files, is the common monorepo shape.
That meant the hook protected the leaves and left the trunk wide open:
eslint.config.base.mjs <- ignore list + rule severities UNPROTECTED
frontend/eslint.config.mjs <- imports the base protected
backend/eslint.config.mjs <- imports the base protected
An agent blocked from touching the two leaves could silently rewrite every rule
severity in the file they both import. Hit in practice: two edits to
`eslint.config.js` were correctly blocked, then an edit to
`eslint.config.base.mjs` in the same repo went through unchallenged.
Adds `PROTECTED_PATTERNS` alongside the existing Set, covering
`<tool>.config.<qualifier>.<ext>` and `.<tool>rc.<qualifier>.<ext>` for the
linters and formatters already listed. Case-insensitive, for the same reason
the Set lookup is (#2543).
Deliberately NOT matched: `vite.config.ts`, `vitest.config.ts`,
`jest.config.js`, `playwright.config.ts`, `tsconfig.json`. This hook exists to
stop a LINTER config being weakened in place of fixing the code; editing a
bundler or test-runner config is ordinary work, and sweeping those in would
make the hook obstructive. A second test pins that boundary so a future
widening of the patterns cannot quietly cross it.
The exact-name Set is untouched, so nothing previously protected becomes
unprotected, and first-time creation stays allowed (the bootstrap path).
Tests: 11 pass. The new regression test was verified failing against the
unpatched hook first; the boundary test passes either way by design and is
there as the control.
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.
* 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.