From 3be035440742b86397cc9e041beea384d8502b07 Mon Sep 17 00:00:00 2001 From: Aman Sharma Date: Sat, 26 Sep 2026 19:42:42 +0200 Subject: [PATCH 1/3] fix: block package-manager CLI commands that pin an exact dependency version Bash writes to a manifest via a redirect/tee/sed-i/etc were already caught by looksLikeManifestWrite, but a package manager's own CLI syntax for pinning an exact version (go get mod@v1.2.3, npm install pkg@1.2.3, pip install pkg==1.2.3, cargo add crate@1.2.3, ...) wrote the manifest just as directly and slipped straight past the hook, exit 0, with no outdated-dependency check at all. Co-Authored-By: Claude Sonnet 5 --- main.go | 21 +++++++++++++++++++++ main_test.go | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/main.go b/main.go index 79cf0298..34b0dfbf 100644 --- a/main.go +++ b/main.go @@ -109,6 +109,23 @@ func looksLikeManifestWrite(cmd string) bool { return writeConstructToManifestRE.MatchString(cmd) || redirectToManifestRE.MatchString(cmd) } +// pkgManagerExactPinRE matches a package manager's own CLI syntax for +// pinning a dependency to an exact version, e.g. `go get mod@v1.2.3`, +// `npm install pkg@1.2.3`, `pip install pkg==1.2.3`, `cargo add crate@1.2.3`. +var pkgManagerExactPinRE = regexp.MustCompile( + `\bgo\s+get\s+` + clause + `*@v\d[\w.\-+]*` + + `|\b(?:npm|pnpm|yarn)\s+(?:install|add|i)\b` + clause + `*@\d[\w.\-+]*` + + `|\bcargo\s+add\b` + clause + `*@\d[\w.\-+]*` + + `|\b(?:pip3?|poetry|uv)\s+(?:install|add)\b` + clause + `*==\d[\w.\-+]*`, +) + +// looksLikePkgManagerExactPin reports whether cmd uses a package manager's +// CLI to pin a dependency to an exact version, bypassing the Write/Edit path +// runHook checks the same way a direct manifest write does. +func looksLikePkgManagerExactPin(cmd string) bool { + return pkgManagerExactPinRE.MatchString(cmd) +} + // runHook is a PreToolUse hook for the Write, Edit, and Bash tools. func runHook() { raw, err := io.ReadAll(os.Stdin) @@ -128,6 +145,10 @@ func runHook() { fmt.Fprintln(os.Stderr, "yul: use the Write or Edit tool to modify dependency manifests, not bash (bash writes bypass the outdated-dependency check)") os.Exit(2) } + if looksLikePkgManagerExactPin(in.ToolInput.Command) { + fmt.Fprintln(os.Stderr, "yul: don't pin an exact dependency version via a package manager's CLI (bypasses the outdated-dependency check) - edit the manifest directly with Write/Edit, or run the install/get without a version to pick up latest") + os.Exit(2) + } os.Exit(0) } diff --git a/main_test.go b/main_test.go index f366f7be..2d75ec43 100644 --- a/main_test.go +++ b/main_test.go @@ -121,3 +121,40 @@ git add pyproject.toml .gitignore`, false}, }) } } + +func TestLooksLikePkgManagerExactPin(t *testing.T) { + tests := []struct { + name string + cmd string + want bool + }{ + {"go get exact version", `go get github.com/davecgh/go-spew@v1.1.0`, true}, + {"npm install exact version", `npm install react@18.2.0`, true}, + {"npm add exact version", `npm add lodash@4.17.20`, true}, + {"yarn add exact version", `yarn add lodash@4.17.20`, true}, + {"pip install exact version", `pip install requests==2.28.0`, true}, + {"pip3 install exact version", `pip3 install requests==2.28.0`, true}, + {"poetry add exact version", `poetry add requests==2.28.0`, true}, + {"uv add exact version", `uv add requests==2.28.0`, true}, + {"cargo add exact version", `cargo add serde@1.0.150`, true}, + + {"go get no version", `go get github.com/davecgh/go-spew`, false}, + {"go get latest", `go get github.com/davecgh/go-spew@latest`, false}, + {"npm install no version", `npm install react`, false}, + {"npm install caret range", `npm install react@^18.2.0`, false}, + {"npm install tilde range", `npm install react@~18.2.0`, false}, + {"pip install no version", `pip install requests`, false}, + {"pip install range", `pip install requests>=2.28.0`, false}, + {"cargo add no version", `cargo add serde`, false}, + {"unrelated at-sign in path", `cat notes@2.txt`, false}, + {"different clause has the pin", `go get github.com/foo/bar; echo done@v1.0.0`, false}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := looksLikePkgManagerExactPin(test.cmd); got != test.want { + t.Errorf("looksLikePkgManagerExactPin(%q) = %v, want %v", test.cmd, got, test.want) + } + }) + } +} From 5b5d587f99282bd2eb5d0ba43b6e7198254bfca2 Mon Sep 17 00:00:00 2001 From: Aman Sharma Date: Sat, 26 Sep 2026 19:52:18 +0200 Subject: [PATCH 2/3] fix: resolve actual latest version instead of blanket-blocking pkg-manager pins Per review feedback on #69: rather than always blocking a package-manager CLI pin (go get/npm install/pip install/cargo add ...@version) outright, resolve its actual latest release the same way the Write/Edit path does (reusing pins.Diff) and only block when the pinned version is genuinely outdated, reporting the correct version the same way. An already-latest CLI pin now passes through untouched instead of always being redirected to Write/Edit. Co-Authored-By: Claude Sonnet 5 --- main.go | 89 ++++++++++++++++++++++++++++++++++++++++++---------- main_test.go | 58 +++++++++++++++++++--------------- 2 files changed, 105 insertions(+), 42 deletions(-) diff --git a/main.go b/main.go index 34b0dfbf..94a06e50 100644 --- a/main.go +++ b/main.go @@ -1,6 +1,7 @@ package main import ( + "context" "encoding/json" "flag" "fmt" @@ -11,6 +12,8 @@ import ( "strings" "time" + "github.com/git-pkgs/purl" + "github.com/chains-project/yul/pkg/cargo" "github.com/chains-project/yul/pkg/githubactions" "github.com/chains-project/yul/pkg/golang" @@ -20,6 +23,7 @@ import ( "github.com/chains-project/yul/pkg/scan" "github.com/chains-project/yul/pkg/util/manifestchecker" "github.com/chains-project/yul/pkg/util/mismatch" + "github.com/chains-project/yul/pkg/util/pins" "github.com/chains-project/yul/pkg/util/resolver" ) @@ -109,21 +113,75 @@ func looksLikeManifestWrite(cmd string) bool { return writeConstructToManifestRE.MatchString(cmd) || redirectToManifestRE.MatchString(cmd) } -// pkgManagerExactPinRE matches a package manager's own CLI syntax for +// pkgManagerPinPatterns matches a package manager's own CLI syntax for // pinning a dependency to an exact version, e.g. `go get mod@v1.2.3`, -// `npm install pkg@1.2.3`, `pip install pkg==1.2.3`, `cargo add crate@1.2.3`. -var pkgManagerExactPinRE = regexp.MustCompile( - `\bgo\s+get\s+` + clause + `*@v\d[\w.\-+]*` + - `|\b(?:npm|pnpm|yarn)\s+(?:install|add|i)\b` + clause + `*@\d[\w.\-+]*` + - `|\bcargo\s+add\b` + clause + `*@\d[\w.\-+]*` + - `|\b(?:pip3?|poetry|uv)\s+(?:install|add)\b` + clause + `*==\d[\w.\-+]*`, -) +// `npm install pkg@1.2.3`, `pip install pkg==1.2.3`, `cargo add crate@1.2.3` +// - these write the manifest just as much as a redirect does, but don't +// match looksLikeManifestWrite's direct-write patterns at all. +var pkgManagerPinPatterns = []struct { + re *regexp.Regexp + scheme string +}{ + {regexp.MustCompile(`\bgo\s+get\s+` + clause + `*?(?P[\w.\-/]+)@(?Pv\d[\w.\-+]*)`), "golang"}, + {regexp.MustCompile(`\b(?:npm|pnpm|yarn)\s+(?:install|add|i)\b` + clause + `*?(?P@[\w.\-]+/[\w.\-]+|[\w.\-]+)@(?P\d[\w.\-+]*)`), "npm"}, + {regexp.MustCompile(`\bcargo\s+add\b` + clause + `*?(?P[\w.\-]+)@(?P\d[\w.\-+]*)`), "cargo"}, + {regexp.MustCompile(`\b(?:pip3?|poetry|uv)\s+(?:install|add)\b` + clause + `*?(?P[\w.\-]+)==(?P\d[\w.\-+]*)`), "pypi"}, +} -// looksLikePkgManagerExactPin reports whether cmd uses a package manager's -// CLI to pin a dependency to an exact version, bypassing the Write/Edit path -// runHook checks the same way a direct manifest write does. -func looksLikePkgManagerExactPin(cmd string) bool { - return pkgManagerExactPinRE.MatchString(cmd) +// parsePkgManagerPin extracts the ecosystem, package name, and pinned +// version from a package manager CLI command, if cmd matches one of +// pkgManagerPinPatterns. +func parsePkgManagerPin(cmd string) (scheme, name, version string, ok bool) { + for _, p := range pkgManagerPinPatterns { + m := p.re.FindStringSubmatch(cmd) + if m == nil { + continue + } + for i, group := range p.re.SubexpNames() { + switch group { + case "name": + name = m[i] + case "version": + version = m[i] + } + } + return p.scheme, name, version, true + } + return "", "", "", false +} + +// checkPkgManagerPin resolves name's latest released version under scheme +// and, if pinnedVersion is older, blocks (exit 2) with the same "outdated +// dependencies" message the Write/Edit path prints - so Claude retries with +// the correct version instead of pinning it via bash and never finding out. +// It exits 0 (fails open) if the purl can't be built or the resolver can't +// find a latest version, same as the Write/Edit path's own resolver errors. +func checkPkgManagerPin(scheme, name, pinnedVersion string) { + res, err := resolver.NewEnrichmentResolver() + if err != nil { + return // fail open: a resolver construction error shouldn't block the command + } + + // The resolver's response keys purls without a version component (see + // pins.Diff / EnrichmentResolver.LatestVersions), same as every other + // checker's manifest-parsed PURLs - so this must match, not carry + // pinnedVersion. + purlStr := purl.BuildPURLString(scheme, name, "", "") + if purlStr == "" { + return + } + + pin := pins.Pin{Name: name, Version: pinnedVersion, PURL: purlStr} + mismatches, err := pins.Diff(context.Background(), nil, map[string]pins.Pin{name: pin}, scheme, res, pins.NoRangeSupport, nil) + if err != nil || len(mismatches) == 0 { + return // fail open on a resolver error; nothing to flag if it's already latest + } + + fmt.Fprintln(os.Stderr, "outdated dependency pinned via package manager CLI, use this version instead:") + for _, m := range mismatches { + fmt.Fprintf(os.Stderr, " %s %s -> %s\n", m.Name, m.Current, m.Latest) + } + os.Exit(2) } // runHook is a PreToolUse hook for the Write, Edit, and Bash tools. @@ -145,9 +203,8 @@ func runHook() { fmt.Fprintln(os.Stderr, "yul: use the Write or Edit tool to modify dependency manifests, not bash (bash writes bypass the outdated-dependency check)") os.Exit(2) } - if looksLikePkgManagerExactPin(in.ToolInput.Command) { - fmt.Fprintln(os.Stderr, "yul: don't pin an exact dependency version via a package manager's CLI (bypasses the outdated-dependency check) - edit the manifest directly with Write/Edit, or run the install/get without a version to pick up latest") - os.Exit(2) + if scheme, name, pinnedVersion, ok := parsePkgManagerPin(in.ToolInput.Command); ok { + checkPkgManagerPin(scheme, name, pinnedVersion) } os.Exit(0) } diff --git a/main_test.go b/main_test.go index 2d75ec43..5e42454c 100644 --- a/main_test.go +++ b/main_test.go @@ -122,38 +122,44 @@ git add pyproject.toml .gitignore`, false}, } } -func TestLooksLikePkgManagerExactPin(t *testing.T) { +func TestParsePkgManagerPin(t *testing.T) { tests := []struct { - name string - cmd string - want bool + testName string + cmd string + wantScheme string + wantName string + wantVer string + wantOK bool }{ - {"go get exact version", `go get github.com/davecgh/go-spew@v1.1.0`, true}, - {"npm install exact version", `npm install react@18.2.0`, true}, - {"npm add exact version", `npm add lodash@4.17.20`, true}, - {"yarn add exact version", `yarn add lodash@4.17.20`, true}, - {"pip install exact version", `pip install requests==2.28.0`, true}, - {"pip3 install exact version", `pip3 install requests==2.28.0`, true}, - {"poetry add exact version", `poetry add requests==2.28.0`, true}, - {"uv add exact version", `uv add requests==2.28.0`, true}, - {"cargo add exact version", `cargo add serde@1.0.150`, true}, + {"go get exact version", `go get github.com/davecgh/go-spew@v1.1.0`, "golang", "github.com/davecgh/go-spew", "v1.1.0", true}, + {"npm install exact version", `npm install react@18.2.0`, "npm", "react", "18.2.0", true}, + {"npm add exact version", `npm add lodash@4.17.20`, "npm", "lodash", "4.17.20", true}, + {"npm scoped package exact version", `npm install @vue/core@3.2.1`, "npm", "@vue/core", "3.2.1", true}, + {"yarn add exact version", `yarn add lodash@4.17.20`, "npm", "lodash", "4.17.20", true}, + {"pip install exact version", `pip install requests==2.28.0`, "pypi", "requests", "2.28.0", true}, + {"pip3 install exact version", `pip3 install requests==2.28.0`, "pypi", "requests", "2.28.0", true}, + {"poetry add exact version", `poetry add requests==2.28.0`, "pypi", "requests", "2.28.0", true}, + {"uv add exact version", `uv add requests==2.28.0`, "pypi", "requests", "2.28.0", true}, + {"cargo add exact version", `cargo add serde@1.0.150`, "cargo", "serde", "1.0.150", true}, - {"go get no version", `go get github.com/davecgh/go-spew`, false}, - {"go get latest", `go get github.com/davecgh/go-spew@latest`, false}, - {"npm install no version", `npm install react`, false}, - {"npm install caret range", `npm install react@^18.2.0`, false}, - {"npm install tilde range", `npm install react@~18.2.0`, false}, - {"pip install no version", `pip install requests`, false}, - {"pip install range", `pip install requests>=2.28.0`, false}, - {"cargo add no version", `cargo add serde`, false}, - {"unrelated at-sign in path", `cat notes@2.txt`, false}, - {"different clause has the pin", `go get github.com/foo/bar; echo done@v1.0.0`, false}, + {"go get no version", `go get github.com/davecgh/go-spew`, "", "", "", false}, + {"go get latest", `go get github.com/davecgh/go-spew@latest`, "", "", "", false}, + {"npm install no version", `npm install react`, "", "", "", false}, + {"npm install caret range", `npm install react@^18.2.0`, "", "", "", false}, + {"npm install tilde range", `npm install react@~18.2.0`, "", "", "", false}, + {"pip install no version", `pip install requests`, "", "", "", false}, + {"pip install range", `pip install requests>=2.28.0`, "", "", "", false}, + {"cargo add no version", `cargo add serde`, "", "", "", false}, + {"unrelated at-sign in path", `cat notes@2.txt`, "", "", "", false}, + {"different clause has the pin", `go get github.com/foo/bar; echo done@v1.0.0`, "", "", "", false}, } for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - if got := looksLikePkgManagerExactPin(test.cmd); got != test.want { - t.Errorf("looksLikePkgManagerExactPin(%q) = %v, want %v", test.cmd, got, test.want) + t.Run(test.testName, func(t *testing.T) { + scheme, name, version, ok := parsePkgManagerPin(test.cmd) + if ok != test.wantOK || scheme != test.wantScheme || name != test.wantName || version != test.wantVer { + t.Errorf("parsePkgManagerPin(%q) = (%q, %q, %q, %v), want (%q, %q, %q, %v)", + test.cmd, scheme, name, version, ok, test.wantScheme, test.wantName, test.wantVer, test.wantOK) } }) } From ab14e16e2852db3f831cac660c79d6ef92717fe9 Mon Sep 17 00:00:00 2001 From: Aman Sharma Date: Sat, 26 Sep 2026 20:07:31 +0200 Subject: [PATCH 3/3] Update main.go --- main.go | 3 --- 1 file changed, 3 deletions(-) diff --git a/main.go b/main.go index 94a06e50..8a897bf2 100644 --- a/main.go +++ b/main.go @@ -128,9 +128,6 @@ var pkgManagerPinPatterns = []struct { {regexp.MustCompile(`\b(?:pip3?|poetry|uv)\s+(?:install|add)\b` + clause + `*?(?P[\w.\-]+)==(?P\d[\w.\-+]*)`), "pypi"}, } -// parsePkgManagerPin extracts the ecosystem, package name, and pinned -// version from a package manager CLI command, if cmd matches one of -// pkgManagerPinPatterns. func parsePkgManagerPin(cmd string) (scheme, name, version string, ok bool) { for _, p := range pkgManagerPinPatterns { m := p.re.FindStringSubmatch(cmd)