Skip to content

fix: don't force-install hooks from the npm postinstall - #1510

Merged
mrexox merged 1 commit into
evilmartians:masterfrom
hamodywe:fix/postinstall-no-force
Sep 7, 2026
Merged

mrexox merged 1 commit into
evilmartians:masterfrom
hamodywe:fix/postinstall-no-force

Conversation

@hamodywe

Copy link
Copy Markdown
Contributor

Closes #1487

Context

--force means two things at once β€” cmd/install.go spells it out: "overwrite .old files and proceed even if core.hooksPath is set". The npm postinstall runs lefthook install -f, so it wants the first half and silently gets the second, and the core.hooksPath guard added in #1292 never fires for anyone installing through npm.

It reaches past the project being installed. npm runs prepare for git dependencies in a temp clone under the npm cache; that clone is itself a git repo, so the user's global core.hooksPath applies to it. Installing an unrelated package can rewrite hooks shared by every repository on the machine, without anyone typing lefthook install.

Reproduced with a binary built from master, in a repo whose core.hooksPath holds a hand-written pre-commit:

$ lefthook install
core.hooksPath is set locally to '.../hooks-global'
Custom hooks paths are not supported by default.
exit=1                      # hook untouched

$ lefthook install -f       # what the postinstall runs today
exit=0
$ head -1 .../hooks-global/pre-commit
#!/bin/sh                   # the lefthook wrapper; the original moved to pre-commit.old

Changes

Drop -f from both postinstall scripts, and note the resulting behavior in the install docs.

Dropping it costs nothing here. cleanHook only consults force when a foreign hook and its .old backup both already exist β€” an existing lefthook hook is matched by isLefthookFile and removed without it, which is the case a postinstall actually hits on upgrade. internal/command/install_test.go already pins that as "with existing lefthook hooks" with force unset, and a second plain lefthook install against a real repo reports sync hooks: βœ”οΈ(pre-commit) and creates no .old.

The only behavior that changes is foreign-hook-plus-existing-.old, which now stops with the existing error instead of overwriting the backup unasked. --force is unchanged and still there for whoever means it.

spawnSync does not check the exit status, so npm install still succeeds; when the guard fires the user sees its message and instructions.

Not addressed here, from the same issue: the generated wrapper embeds the install-time absolute binary path. That's a separate concern and I'd rather it got its own issue than rode along on this one.

Checklist

  • make lint passes β€” go vet ./... clean; golangci-lint not run locally (Windows), CI will cover it
  • make test passes β€” full go test ./... green
  • Docs updated if behavior changed or new config option added

Branch note: based on the 2.1.11 commit rather than current master, because the token used here lacks the workflow scope needed to push newer upstream commits that touch .github/workflows/. None of the three files touched differ between the two, so it applies to master unchanged β€” happy to rebase.

AI disclosure: this change was prepared with the assistance of Claude Code. I reviewed the change and the verification before opening, and I stand behind them as my own.

--force means two things: overwrite .old files, and proceed even if
core.hooksPath is set. The postinstall wanted the first and was silently
getting the second, so the guard added in evilmartians#1292 never fired for anyone
installing through npm.

It reaches further than the project being installed. npm runs prepare for
git dependencies in a temp clone under the npm cache, which is itself a git
repo, so the user's *global* core.hooksPath applies there. Installing an
unrelated package can therefore rewrite hooks shared by every repository on
the machine, without the user typing lefthook install.

Dropping -f costs nothing here. cleanHook only consults force when a foreign
hook and its .old backup both already exist; an existing lefthook hook is
recognised and replaced without it, which is the case a postinstall actually
hits on upgrade. That path is already covered by "with existing lefthook
hooks" in internal/command/install_test.go, which passes force=false.

Closes evilmartians#1487

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@hamodywe
hamodywe requested a review from mrexox as a code owner August 29, 2026 14:21
@greptile-apps

greptile-apps Bot commented Aug 29, 2026

Copy link
Copy Markdown

Confidence Score: 3/5

This PR should not merge until the maintained npm-installer distribution is prevented from retaining the same automatic global-hooks overwrite path.

Two postinstall variants now respect core.hooksPath, but a currently published and documented npm package still runs the same installer with force automatically; the recovery documentation also conflates a temporary override with a persistent reset.

Files Needing Attention: packaging/registries/npm/lefthook/postinstall.js, packaging/registries/npm-installer/install.js, docs/usage/commands/install.md

Security Review

The maintained @evilmartians/lefthook-installer package still automatically invokes lefthook install -f, so installations through that distribution continue to bypass the core.hooksPath boundary and can modify shared hooks.

Reviews (1): Last reviewed commit: "fix: don't force-install hooks from the ..." | Re-trigger Greptile

}

spawnSync(getExePath(), ["install", "-f"], {
// No -f here. --force also tells `install` to proceed when core.hooksPath is

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security Installer variant still bypasses guard

When a user with global core.hooksPath installs the maintained @evilmartians/lefthook-installer package, its automatic lifecycle script still runs lefthook install -f, causing unattended installation to overwrite hooks in the shared directory despite this safety fix. How this was verified: The published package's install lifecycle invokes the same CLI with -f, which makes the hooks-path check proceed instead of refusing.

::: callout info Note
NPM package `lefthook` installs the hooks in a postinstall script automatically. For projects not using NPM package run `lefthook install` after cloning the repo.

The postinstall script runs a plain `lefthook install`, so when `core.hooksPath` is set it stops and prints the same message as running the command by hand. Run `lefthook install --force` or `lefthook install --reset-hooks-path` once, deliberately, to resolve it.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Force is not persistent resolution

The new guidance says either command resolves the conflict when run once, but --force only bypasses core.hooksPath for the current invocation. The next plain postinstall stops again; only --reset-hooks-path persistently removes the conflicting setting.

Suggested change
The postinstall script runs a plain `lefthook install`, so when `core.hooksPath` is set it stops and prints the same message as running the command by hand. Run `lefthook install --force` or `lefthook install --reset-hooks-path` once, deliberately, to resolve it.
The postinstall script runs a plain `lefthook install`, so when `core.hooksPath` is set it stops and prints the same message as running the command by hand. Run `lefthook install --force` to override the check for one installation, or run `lefthook install --reset-hooks-path` once to remove the setting.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

@mrexox mrexox left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM!

@mrexox
mrexox merged commit 3667aeb into evilmartians:master Sep 7, 2026
13 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

npm postinstall runs lefthook install -f, bypassing the core.hooksPath guard added in #1292

2 participants