From efa8826a18b0ad40e4ed70b81e3ee57485d4de65 Mon Sep 17 00:00:00 2001 From: Swarit Pandey Date: Fri, 21 Aug 2026 10:25:44 +0530 Subject: [PATCH 1/4] feat(versionmeta): add dpkg, snap and appimage static sources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Linux installs carry their version on disk in three places the resolver could not read, so tools installed by any of them fell through to exec'ing the binary — the path this package exists to avoid. - dpkg: the version of the package whose own file manifest lists the binary. Ownership is proved from the manifest rather than assumed from a matching package name, so a stale `foo` deb cannot lend its version to a hand-installed /usr/local/bin/foo, and purged stanzas (whose recorded version no longer describes anything on disk) are rejected. The multi-arch `:.list` glob only runs when the plain name misses: /var/lib/dpkg/info holds tens of thousands of entries on Ubuntu, and one directory read per probed tool adds up. - snap: the version field of /snap//current/meta/snap.yaml. No path rule could reach this — /snap/bin/ is a symlink to the snap wrapper, so resolving it walks away from the install rather than into it. The tool name is deliberately not part of the check, because `snap run .` exposes apps under names that differ from the snap's own; the manifest path already identifies the provider. - AppImage: the version in the filename. A single-file install has no package entry and no install tree, so the name upstream chose is the only static source there is. The tool-name prefix must match, and a name carrying no version (nvim-linux-x86_64.AppImage) yields nothing rather than a guess at "linux". All three are plain file reads: no dpkg-query, no snap info. This mirrors the static pacman-database reads already in detector/aicli.go. The aicli resolver harness whitelists the glob patterns its ladders may issue; the dpkg pattern is matched by prefix there because the tool name varies per case, and it reads the package database rather than launching anything. Signed-off-by: Swarit Pandey --- internal/detector/aicli_agents_test.go | 6 + internal/versionmeta/linux.go | 186 ++++++++++++++++++ internal/versionmeta/linux_test.go | 259 +++++++++++++++++++++++++ internal/versionmeta/versionmeta.go | 21 +- 4 files changed, 471 insertions(+), 1 deletion(-) create mode 100644 internal/versionmeta/linux.go create mode 100644 internal/versionmeta/linux_test.go diff --git a/internal/detector/aicli_agents_test.go b/internal/detector/aicli_agents_test.go index 48d35ae0..e31e551e 100644 --- a/internal/detector/aicli_agents_test.go +++ b/internal/detector/aicli_agents_test.go @@ -416,6 +416,12 @@ func runAICLICase(t *testing.T, tc aicliCase) { allowed := aicliAllowedGlobs(home, goos) for _, pattern := range rec.globs { + // versionmeta's dpkg source globs one :.list per candidate. + // Matched by prefix since the tool name varies per case; it reads the + // package database and launches nothing. + if strings.HasPrefix(pattern, "/var/lib/dpkg/info/") { + continue + } if !allowed[pattern] { t.Errorf("unexpected Glob(%q); the ladders may only glob the targeted install trees", pattern) } diff --git a/internal/versionmeta/linux.go b/internal/versionmeta/linux.go new file mode 100644 index 00000000..32e1e97b --- /dev/null +++ b/internal/versionmeta/linux.go @@ -0,0 +1,186 @@ +// Linux static version sources: package database, snap manifest, AppImage +// filename. Nothing here launches the tool. + +package versionmeta + +import ( + "strings" + + "github.com/step-security/dev-machine-guard/internal/executor" +) + +const ( + dpkgStatusPath = "/var/lib/dpkg/status" + dpkgInfoDir = "/var/lib/dpkg/info" +) + +// versionFromDpkg returns the version of the Debian/Ubuntu package that owns +// one of paths. Read straight off the dpkg database, no dpkg-query. +// +// Ownership is proved from the package's own file manifest, never assumed +// from a matching name: a stale `foo` package and a hand-installed +// /usr/local/bin/foo coexist happily and their versions differ. Only packages +// named exactly `base` are considered — scanning every *.list means reading +// thousands of files. +func versionFromDpkg(exec executor.Executor, base string, paths []string) string { + if base == "" { + return "" + } + if v := dpkgVersionIfOwned(exec, dpkgInfoDir+"/"+base+".list", base, paths); v != "" { + return v + } + + // Multi-Arch: same records the file list as `:.list`. Finding + // those needs a glob over a directory holding tens of thousands of entries + // on Ubuntu, so it only runs when the plain name missed. + entries, err := exec.Glob(dpkgInfoDir + "/" + base + ":*.list") + if err != nil { + return "" + } + for _, manifest := range entries { + if v := dpkgVersionIfOwned(exec, manifest, base, paths); v != "" { + return v + } + } + return "" +} + +// dpkgVersionIfOwned returns pkg's version if the manifest lists one of paths. +func dpkgVersionIfOwned(exec executor.Executor, manifest, pkg string, paths []string) string { + data, err := exec.ReadFile(manifest) + if err != nil { + return "" + } + if !dpkgManifestLists(string(data), paths) { + return "" + } + return dpkgStatusVersion(exec, pkg) +} + +// dpkgManifestLists reports whether a `.list` manifest (one absolute path per +// line) names any of paths. dpkg lists a packaged symlink and its target, so +// callers can pass the binary as found and its resolved form. +func dpkgManifestLists(manifest string, paths []string) bool { + for _, line := range strings.Split(manifest, "\n") { + line = strings.TrimRight(line, "\r") + for _, p := range paths { + if p != "" && line == p { + return true + } + } + } + return false +} + +// dpkgStatusVersion reads the Version field of pkg's blank-line-delimited +// stanza in /var/lib/dpkg/status. Only "installed" counts: dpkg keeps stanzas +// for purged packages, whose version describes nothing on disk. +func dpkgStatusVersion(exec executor.Executor, pkg string) string { + data, err := exec.ReadFile(dpkgStatusPath) + if err != nil { + return "" + } + var name, version string + var installed bool + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimRight(line, "\r") + if line == "" { // stanza boundary + if name == pkg && installed { + return normalizeDebianVersion(version) + } + name, version, installed = "", "", false + continue + } + switch { + case strings.HasPrefix(line, "Package:"): + name = strings.TrimSpace(strings.TrimPrefix(line, "Package:")) + case strings.HasPrefix(line, "Version:"): + version = strings.TrimSpace(strings.TrimPrefix(line, "Version:")) + case strings.HasPrefix(line, "Status:"): + installed = strings.HasSuffix(strings.TrimSpace(line), " installed") + } + } + if name == pkg && installed { // file not newline-terminated + return normalizeDebianVersion(version) + } + return "" +} + +// normalizeDebianVersion reduces [epoch:]upstream[-revision] to the upstream +// part, matching what the tool reports about itself: "1:0.3.31-2" -> "0.3.31". +// Per Debian policy the revision is everything after the last hyphen, so this +// is exact rather than heuristic. +func normalizeDebianVersion(v string) string { + if i := strings.Index(v, ":"); i >= 0 { + v = v[i+1:] + } + if i := strings.LastIndex(v, "-"); i > 0 { + v = v[:i] + } + if !isVersionLike(v) { + return "" + } + return v +} + +// versionFromAppImage extracts the version from an AppImage filename +// (LM-Studio-0.3.31-x64.AppImage -> 0.3.31). A single-file install has no +// install tree and no package entry, so the filename is the only static +// source. The segments before the version must name the tool, so a symlink +// into someone else's AppImage can't lend its version. +func versionFromAppImage(resolved, base string) string { + segments := splitPath(resolved) + name := segments[len(segments)-1] + if !strings.HasSuffix(strings.ToLower(name), ".appimage") { + return "" + } + stem := name[:len(name)-len(".AppImage")] + + parts := strings.Split(stem, "-") + for i := 1; i < len(parts); i++ { + if !isVersionLike(parts[i]) { + continue + } + if matchesTool(strings.Join(parts[:i], "-"), base) { + return strings.TrimPrefix(parts[i], "v") + } + } + return "" +} + +// versionFromSnap reads the version from /snap//current/meta/snap.yaml. +// The path rules can't reach it: /snap/bin/ symlinks to the snap +// wrapper, so resolving it walks away from the install. +// +// That path is definitionally the right snap's manifest, so unlike dpkg no +// ownership proof is needed; `name:` is checked only against the directory it +// came from. The tool name is not compared — `snap run .` exposes +// apps under names that differ from the snap's own. +func versionFromSnap(exec executor.Executor, binaryPath string) string { + segments := splitPath(binaryPath) + if len(segments) < 3 || segments[0] != "snap" || segments[1] != "bin" { + return "" + } + snapName := segments[2] + if i := strings.Index(snapName, "."); i > 0 { + snapName = snapName[:i] + } + data, err := exec.ReadFile("/snap/" + snapName + "/current/meta/snap.yaml") + if err != nil { + return "" + } + + var name, version string + for _, line := range strings.Split(string(data), "\n") { + switch { + case strings.HasPrefix(line, "name:"): + name = strings.Trim(strings.TrimSpace(strings.TrimPrefix(line, "name:")), `"'`) + case strings.HasPrefix(line, "version:"): + version = strings.Trim(strings.TrimSpace(strings.TrimPrefix(line, "version:")), `"'`) + } + } + if name != snapName || !isVersionLike(version) { + return "" + } + return strings.TrimPrefix(version, "v") +} diff --git a/internal/versionmeta/linux_test.go b/internal/versionmeta/linux_test.go new file mode 100644 index 00000000..8aa3fde1 --- /dev/null +++ b/internal/versionmeta/linux_test.go @@ -0,0 +1,259 @@ +package versionmeta + +import ( + "context" + "testing" + + "github.com/step-security/dev-machine-guard/internal/executor" +) + +// The Ubuntu box from the LM Studio report: the .deb's launcher on PATH, +// symlinked into the electron-builder install root. +func dpkgMock(list, status string) *executor.Mock { + mock := executor.NewMock() + mock.SetGOOS("linux") + mock.SetSymlink("/usr/bin/lm-studio", "/opt/LM Studio/lm-studio") + if list != "" { + mock.SetFile("/var/lib/dpkg/info/lm-studio.list", []byte(list)) + } + if status != "" { + mock.SetFile("/var/lib/dpkg/status", []byte(status)) + } + return mock +} + +const dpkgOwnedList = "/opt\n/opt/LM Studio\n/opt/LM Studio/lm-studio\n/usr/bin/lm-studio\n" + +func TestFromBinary_Dpkg(t *testing.T) { + tests := []struct { + name string + list string + status string + want string + }{ + { + name: "package owns the launcher", + list: dpkgOwnedList, + status: "Package: lm-studio\nStatus: install ok installed\nVersion: 0.3.31-1\n\n", + want: "0.3.31", + }, + { + // Matching either the PATH entry or its target is enough. + name: "matches the resolved target", + list: "/opt/LM Studio/lm-studio\n", + status: "Package: lm-studio\nStatus: install ok installed\nVersion: 0.3.31\n\n", + want: "0.3.31", + }, + { + // A stale deb and a hand-installed launcher coexist; lending one's + // version to the other is a silent wrong answer. + name: "same-named package that owns nothing is rejected", + list: "/usr/share/doc/lm-studio/copyright\n", + status: "Package: lm-studio\nStatus: install ok installed\nVersion: 0.2.9\n\n", + want: "", + }, + { + name: "purged package is rejected", + list: dpkgOwnedList, + status: "Package: lm-studio\nStatus: deinstall ok config-files\nVersion: 0.2.9\n\n", + want: "", + }, + { + name: "epoch is stripped", + list: dpkgOwnedList, + status: "Package: lm-studio\nStatus: install ok installed\nVersion: 2:0.3.31-1ubuntu2\n\n", + want: "0.3.31", + }, + { + // Neither first nor last, and no leaking across stanza boundaries. + name: "finds the stanza among others", + list: dpkgOwnedList, + status: "Package: zlib1g\nStatus: install ok installed\nVersion: 1:1.2.11.dfsg-2\n\n" + + "Package: lm-studio\nStatus: install ok installed\nVersion: 0.3.31-1\n\n" + + "Package: zsh\nStatus: install ok installed\nVersion: 5.8.1-1\n", + want: "0.3.31", + }, + { + name: "unparseable version falls through to the caller's fallback", + list: dpkgOwnedList, + status: "Package: lm-studio\nStatus: install ok installed\nVersion: nightly\n\n", + want: "", + }, + { + name: "no dpkg database at all", + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := FromBinary(context.Background(), dpkgMock(tt.list, tt.status), "/usr/bin/lm-studio") + if got != tt.want { + t.Errorf("FromBinary = %q, want %q", got, tt.want) + } + }) + } +} + +// /var/lib/dpkg on a Mac never describes what is installed there. +func TestFromBinary_DpkgIsLinuxOnly(t *testing.T) { + mock := dpkgMock(dpkgOwnedList, "Package: lm-studio\nStatus: install ok installed\nVersion: 0.3.31-1\n\n") + mock.SetGOOS("darwin") + + if got := FromBinary(context.Background(), mock, "/usr/bin/lm-studio"); got != "" { + t.Errorf("FromBinary = %q, want \"\" on darwin", got) + } +} + +// Multi-Arch: same packages record their file list under :.list. +func TestFromBinary_DpkgMultiarchManifest(t *testing.T) { + mock := executor.NewMock() + mock.SetGOOS("linux") + mock.SetSymlink("/usr/bin/local-ai", "/usr/bin/local-ai") + mock.SetGlob("/var/lib/dpkg/info/local-ai:*.list", []string{"/var/lib/dpkg/info/local-ai:amd64.list"}) + mock.SetFile("/var/lib/dpkg/info/local-ai:amd64.list", []byte("/usr/bin/local-ai\n")) + mock.SetFile("/var/lib/dpkg/status", []byte( + "Package: local-ai\nStatus: install ok installed\nVersion: 2.24.1-3\nArchitecture: amd64\n\n")) + + if got := FromBinary(context.Background(), mock, "/usr/bin/local-ai"); got != "2.24.1" { + t.Errorf("FromBinary = %q, want 2.24.1", got) + } +} + +func TestFromBinary_AppImage(t *testing.T) { + tests := []struct { + name string + binary string + resolved string + want string + }{ + { + // The LM Studio install dpkg can't see: one file, no package entry. + name: "electron-builder naming", + binary: "/home/dev/.local/bin/lm-studio", + resolved: "/home/dev/Applications/LM-Studio-0.3.31-x64.AppImage", + want: "0.3.31", + }, + { + name: "debian-style revision after the version", + binary: "/home/dev/.local/bin/lm-studio", + resolved: "/home/dev/Applications/LM-Studio-0.3.31-1-x64.AppImage", + want: "0.3.31", + }, + { + name: "single-segment product name", + binary: "/usr/local/bin/cursor", + resolved: "/opt/appimages/Cursor-1.5.9-x86_64.AppImage", + want: "1.5.9", + }, + { + // "linux" must not be mistaken for a version. + name: "no version in the filename", + binary: "/usr/local/bin/nvim", + resolved: "/opt/appimages/nvim-linux-x86_64.AppImage", + want: "", + }, + { + name: "prefix names a different tool", + binary: "/usr/local/bin/lm-studio", + resolved: "/opt/appimages/Cursor-1.5.9-x86_64.AppImage", + want: "", + }, + { + name: "not an AppImage at all", + binary: "/usr/local/bin/lm-studio", + resolved: "/opt/lm-studio/LM-Studio-0.3.31-x64.bin", + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mock := executor.NewMock() + mock.SetGOOS("linux") + mock.SetSymlink(tt.binary, tt.resolved) + if got := FromBinary(context.Background(), mock, tt.binary); got != tt.want { + t.Errorf("FromBinary = %q, want %q", got, tt.want) + } + }) + } +} + +func TestFromBinary_Snap(t *testing.T) { + // /snap/bin/ symlinks to the snap wrapper, so resolving the binary + // walks away from the install — the manifest is the only way in. + snapMock := func(yaml string) *executor.Mock { + mock := executor.NewMock() + mock.SetGOOS("linux") + mock.SetSymlink("/snap/bin/local-ai", "/usr/bin/snap") + if yaml != "" { + mock.SetFile("/snap/local-ai/current/meta/snap.yaml", []byte(yaml)) + } + return mock + } + + tests := []struct { + name string + yaml string + want string + }{ + { + name: "manifest names this snap", + yaml: "name: local-ai\nversion: 2.24.1\nsummary: LocalAI\nbase: core22\n", + want: "2.24.1", + }, + { + name: "quoted version", + yaml: "name: local-ai\nversion: '2.24.1'\n", + want: "2.24.1", + }, + { + name: "manifest disagrees with its own directory", + yaml: "name: something-else\nversion: 9.9.9\n", + want: "", + }, + { + // Snap versions are free-form strings. + name: "non-version version string", + yaml: "name: local-ai\nversion: stable\n", + want: "", + }, + { + name: "no manifest", + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := FromBinary(context.Background(), snapMock(tt.yaml), "/snap/bin/local-ai"); got != tt.want { + t.Errorf("FromBinary = %q, want %q", got, tt.want) + } + }) + } +} + +// `snap run .` exposes apps under a name that differs from the +// snap's own, so the tool name is not part of the check. +func TestFromBinary_SnapSecondaryApp(t *testing.T) { + mock := executor.NewMock() + mock.SetGOOS("linux") + mock.SetSymlink("/snap/bin/local-ai.cli", "/usr/bin/snap") + mock.SetFile("/snap/local-ai/current/meta/snap.yaml", []byte("name: local-ai\nversion: 2.24.1\n")) + + if got := FromBinary(context.Background(), mock, "/snap/bin/local-ai.cli"); got != "2.24.1" { + t.Errorf("FromBinary = %q, want 2.24.1", got) + } +} + +// A snap alias has no directory of its own, so there is no manifest to read. +func TestFromBinary_SnapAliasHasNoManifest(t *testing.T) { + mock := executor.NewMock() + mock.SetGOOS("linux") + mock.SetSymlink("/snap/bin/lai", "/usr/bin/snap") + mock.SetFile("/snap/local-ai/current/meta/snap.yaml", []byte("name: local-ai\nversion: 2.24.1\n")) + + if got := FromBinary(context.Background(), mock, "/snap/bin/lai"); got != "" { + t.Errorf("FromBinary = %q, want \"\"", got) + } +} diff --git a/internal/versionmeta/versionmeta.go b/internal/versionmeta/versionmeta.go index b1841610..5574f773 100644 --- a/internal/versionmeta/versionmeta.go +++ b/internal/versionmeta/versionmeta.go @@ -40,7 +40,15 @@ import ( // means the formula owns the binary, so is its version (npm-installed // binaries under a brew node live in node_modules and are claimed or // rejected by rule 1 before this can misfire). -// 4. macOS app bundle: CFBundleShortVersionString of the enclosing .app. +// 4. dpkg (Linux): the version of the package whose own file manifest lists +// this path. A binary dpkg still owns but that self-updated in place +// reports the packaged version; rules 1-3 claim the layouts where that +// happens before this is reached. +// 5. snap (Linux): the `version` field of the snap manifest, which the path +// rules can't reach (/snap/bin/ symlinks to the snap wrapper). +// 6. AppImage (Linux): the version in the filename, the only static source a +// single-file install has. +// 7. macOS app bundle: CFBundleShortVersionString of the enclosing .app. func FromBinary(ctx context.Context, exec executor.Executor, binaryPath string) string { if binaryPath == "" { return "" @@ -67,6 +75,17 @@ func FromBinary(ctx context.Context, exec executor.Executor, binaryPath string) if v := versionFromHomebrew(resolved); v != "" { return v } + if exec.GOOS() == model.PlatformLinux { + if v := versionFromDpkg(exec, base, []string{binaryPath, resolved}); v != "" { + return v + } + if v := versionFromSnap(exec, binaryPath); v != "" { + return v + } + if v := versionFromAppImage(resolved, base); v != "" { + return v + } + } if exec.GOOS() == model.PlatformDarwin { if v := versionFromAppBundle(ctx, exec, resolved); v != "" { return v From 3de651e06c9d4b1abeecec8aa9e47fb292c1bbe7 Mon Sep 17 00:00:00 2001 From: Swarit Pandey Date: Fri, 21 Aug 2026 10:25:44 +0530 Subject: [PATCH 2/4] feat(execguard): refuse electron app entry points on linux MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit execguard was a macOS-only gate: SafeToExec returned true immediately on every other platform, so every Linux exec fallback in the codebase ran unguarded. The LM Studio failure — a scan launching a desktop app on an Ubuntu customer's machine — therefore had no systemic defense, only per-tool knowledge that a given name isn't a CLI. On Linux the popup has a different cause than macOS and the same effect: a packaged Electron app does not implement --version (only the unpackaged `electron` binary's default app does), so the flag is ignored and the window opens. That is decidable from disk — an Electron bundle ships resources/app.asar and the Chromium runtime beside its executable, and nothing else does — so the exec is skipped rather than attempted, and the verdict costs no subprocess at all. Only the binary's OWN directory is examined, which is exactly what separates the app from its CLI. A VS Code fork ships both: /usr/share/code/code sits beside libffmpeg.so and is refused, while the shim at /usr/share/code/bin/code does not and is allowed. Checking the parent as well — the way the macOS quarantine probe must, because cask installs mark whole trees — would reject precisely the shims we need. The Electron scope is deliberate rather than complete: it covers what has been observed in the field. A GTK or Qt application on $PATH is not detected as one and would need its own signal. macOS Gatekeeper behavior and Windows are unchanged. Signed-off-by: Swarit Pandey --- internal/execguard/execguard.go | 58 ++++++++++++-- internal/execguard/execguard_test.go | 113 +++++++++++++++++++++++++++ 2 files changed, 166 insertions(+), 5 deletions(-) diff --git a/internal/execguard/execguard.go b/internal/execguard/execguard.go index 38407633..4807c5e7 100644 --- a/internal/execguard/execguard.go +++ b/internal/execguard/execguard.go @@ -10,6 +10,11 @@ // would happen, so version probes can skip the exec (reporting "unknown") // instead of triggering it. This generalizes the existing IsAppleCLTStub // guard (which prevents the analogous Command Line Tools install prompt). +// +// On Linux the cause differs and the effect is the same: a packaged Electron +// app does not implement --version (only the unpackaged `electron` binary's +// default app does), so the flag is ignored and its window opens on the user's +// desktop. Reported by an Ubuntu 22.04 customer via LM Studio. package execguard import ( @@ -24,7 +29,7 @@ import ( const probeTimeout = 5 * time.Second // SafeToExec reports whether launching binaryPath is safe from a -// GUI-popup perspective. Non-macOS platforms always return true. +// GUI-popup perspective. Windows always returns true. // // On macOS it resolves symlinks, then checks the binary and its containing // directory for the com.apple.quarantine attribute (cask installs quarantine @@ -37,19 +42,62 @@ const probeTimeout = 5 * time.Second // Both probes execute only Apple-provided utilities (/usr/bin/xattr, // /usr/sbin/spctl), which carry none of the third-party-binary risk this // package exists to avoid. +// +// On Linux it answers whether the binary is an Electron app's GUI entry point, +// purely from stats. func SafeToExec(ctx context.Context, exec executor.Executor, binaryPath string) bool { - if exec.GOOS() != model.PlatformDarwin || binaryPath == "" { + if binaryPath == "" { return true } resolved, err := exec.EvalSymlinks(binaryPath) if err != nil || resolved == "" { resolved = binaryPath } - if !isQuarantined(ctx, exec, resolved) && !isQuarantined(ctx, exec, parentDir(resolved)) { + + switch exec.GOOS() { + case model.PlatformLinux: + return !isElectronAppEntryPoint(exec, resolved) + case model.PlatformDarwin: + if !isQuarantined(ctx, exec, resolved) && !isQuarantined(ctx, exec, parentDir(resolved)) { + return true + } + _, _, exitCode, err := exec.RunWithTimeout(ctx, probeTimeout, "/usr/sbin/spctl", "--assess", "--type", "execute", resolved) + return err == nil && exitCode == 0 + default: return true } - _, _, exitCode, err := exec.RunWithTimeout(ctx, probeTimeout, "/usr/sbin/spctl", "--assess", "--type", "execute", resolved) - return err == nil && exitCode == 0 +} + +// electronBundleMarkers are files an Electron app ships beside its executable +// and nothing else ships: the packed app archive and the Chromium runtime. +var electronBundleMarkers = []string{ + "resources/app.asar", + "libffmpeg.so", + "chrome_100_percent.pak", + "icudtl.dat", +} + +// isElectronAppEntryPoint reports whether resolved is the GUI executable at +// the root of an Electron app tree. +// +// Only the binary's OWN directory is examined, which is what separates the app +// from its CLI: /usr/share/code/code sits beside libffmpeg.so, while the shim +// at /usr/share/code/bin/code does not. Checking the parent too — as the macOS +// quarantine probe must, since cask installs mark whole trees — would reject +// exactly the shims we need. +// +// Electron-only is deliberate: a GTK or Qt app on $PATH needs its own signal. +func isElectronAppEntryPoint(exec executor.Executor, resolved string) bool { + dir := parentDir(resolved) + if dir == "" { + return false + } + for _, marker := range electronBundleMarkers { + if exec.FileExists(dir + "/" + marker) { + return true + } + } + return false } // isQuarantined reports whether path carries the com.apple.quarantine diff --git a/internal/execguard/execguard_test.go b/internal/execguard/execguard_test.go index 0e4a8260..a04c8230 100644 --- a/internal/execguard/execguard_test.go +++ b/internal/execguard/execguard_test.go @@ -3,6 +3,7 @@ package execguard import ( "context" "testing" + "time" "github.com/step-security/dev-machine-guard/internal/executor" ) @@ -87,3 +88,115 @@ func TestSafeToExec(t *testing.T) { } }) } + +// Linux: Electron app entry points. The distinction that matters is app-root +// vs. CLI-shim, and it is visible on disk — the shim lives one directory down, +// beside none of the bundle files. +func linuxMock(files ...string) *executor.Mock { + mock := executor.NewMock() + mock.SetGOOS("linux") + for _, f := range files { + mock.SetFile(f, []byte{}) + } + return mock +} + +func TestSafeToExec_Linux(t *testing.T) { + tests := []struct { + name string + binary string + symlink string // resolved target, "" for none + files []string + want bool + }{ + { + name: "LM Studio's .deb launcher is refused", + binary: "/usr/bin/lm-studio", + symlink: "/opt/LM Studio/lm-studio", + files: []string{"/opt/LM Studio/resources/app.asar", "/opt/LM Studio/libffmpeg.so"}, + want: false, + }, + { + // The case the guard must not break. + name: "a VS Code fork's CLI shim is allowed", + binary: "/usr/share/code/bin/code", + files: []string{"/usr/share/code/resources/app.asar", "/usr/share/code/libffmpeg.so"}, + want: true, + }, + { + name: "the same fork's GUI binary is refused", + binary: "/usr/share/code/code", + files: []string{"/usr/share/code/resources/app.asar", "/usr/share/code/libffmpeg.so"}, + want: false, + }, + { + name: "Chromium runtime data alone is enough to identify a bundle", + binary: "/opt/Some App/some-app", + files: []string{"/opt/Some App/icudtl.dat"}, + want: false, + }, + { + name: "an ordinary CLI in /usr/bin is allowed", + binary: "/usr/bin/ollama", + want: true, + }, + { + name: "a CLI beside other CLIs is allowed", + binary: "/usr/local/bin/ollama", + files: []string{"/usr/local/bin/lm-studio", "/usr/local/bin/code"}, + want: true, + }, + { + name: "a bare binary name with no directory is allowed", + binary: "ollama", + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mock := linuxMock(tt.files...) + if tt.symlink != "" { + mock.SetSymlink(tt.binary, tt.symlink) + } + if got := SafeToExec(context.Background(), mock, tt.binary); got != tt.want { + t.Errorf("SafeToExec(%q) = %v, want %v", tt.binary, got, tt.want) + } + }) + } +} + +// The Linux arm must reach for no subprocess at all. +func TestSafeToExec_LinuxLaunchesNothing(t *testing.T) { + mock := linuxMock("/opt/LM Studio/resources/app.asar") + mock.SetSymlink("/usr/bin/lm-studio", "/opt/LM Studio/lm-studio") + trap := &trapExecutor{Mock: mock, t: t} + + if SafeToExec(context.Background(), trap, "/usr/bin/lm-studio") { + t.Error("Electron app entry point must be refused") + } +} + +type trapExecutor struct { + *executor.Mock + t *testing.T +} + +func (e *trapExecutor) Run(_ context.Context, name string, args ...string) (string, string, int, error) { + e.t.Fatalf("unexpected exec: %s %v", name, args) + return "", "", -1, nil +} + +func (e *trapExecutor) RunWithTimeout(ctx context.Context, _ time.Duration, name string, args ...string) (string, string, int, error) { + return e.Run(ctx, name, args...) +} + +// Windows is unchanged. +func TestSafeToExec_WindowsAlwaysSafe(t *testing.T) { + mock := executor.NewMock() + mock.SetGOOS("windows") + mock.SetFile(`C:\Program Files\LM Studio\resources\app.asar`, []byte{}) + if !SafeToExec(context.Background(), mock, `C:\Program Files\LM Studio\LM Studio.exe`) { + t.Error("Windows must be unaffected") + } +} From 671575d37fae8277363e00703d020686b695f039 Mon Sep 17 00:00:00 2001 From: Swarit Pandey Date: Fri, 21 Aug 2026 10:25:44 +0530 Subject: [PATCH 3/4] fix(detector): stop launching desktop apps for version probes on linux MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An Ubuntu 22.04 customer running the agent from a systemd timer had LM Studio's window open on their desktop mid-scan. `lm-studio` names the desktop application's launcher, not a CLI — LM Studio's CLI is a separate binary, `lms` — and a packaged Electron app ignores --version, so the probe of /usr/bin/lm-studio (the .deb's launcher) booted the app and then sat on RunWithTimeout's full 10s deadline before being killed. Framework specs now carry a per-tool GUIApp flag that suppresses the --version fallback: a GUI entry point is reported as installed with whatever on-disk metadata yields, and "unknown" otherwise, rather than being launched. The flag is opt-in per entry — ollama, LocalAI and Text Generation WebUI are real CLIs and are still exec'd exactly as before. /opt/LM Studio (electron-builder's .deb root) joins the Linux GUI-app candidates. Two paths in the IDE detector had the same shape, and it is the table with the most GUI binaries in reach since every spec names a desktop app: - resolveLinuxVersion tried / as an exec candidate ahead of product-info.json and .eclipseproduct. For every VS Code fork that path is the Electron GUI binary, not the CLI (/opt/Cursor/cursor launches Cursor; the CLI is bin/cursor), so an install whose package.json had moved would launch the app. The metadata reads now come first and only the shim is ever an exec target. - detectLinux's PATH fallback went straight to ` --version`, the same launch-it-sight-unseen shape as the LM Studio bug. It now resolves the symlink and walks up to the install root's package.json / product-info.json first, which also yields a better version than the shim prints. That path stayed harmless only because the four specs carrying a VersionFlag are VS Code forks whose shim really is a CLI; nothing structural was keeping the next GUI-app entry off it. runVersionCmd also consults execguard, which the IDE detector previously did not — the one version-probe path in the codebase that never did. The audit behind this covered every exec site in internal/ and cmd/. What is left is either an OS-provided utility (pgrep, tasklist, reg, ioreg, sw_vers, PlistBuddy, spctl, pluginkit, dmidecode) or a package manager being asked to compute something a file read cannot reproduce (npm config ls -l resolves the config cascade; rpm -qa enumerates a database). The aicli and agent tables were checked entry by entry: all 17 are real CLIs. Signed-off-by: Swarit Pandey --- CHANGELOG.md | 12 +++ SCAN_COVERAGE.md | 19 ++++- internal/detector/framework.go | 32 ++++++-- internal/detector/framework_test.go | 96 ++++++++++++++++++++++ internal/detector/ide.go | 92 ++++++++++++++++------ internal/detector/ide_test.go | 118 ++++++++++++++++++++++++++++ 6 files changed, 340 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index db54010c..2388bbf4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 See [VERSIONING.md](VERSIONING.md) for why the version starts at 1.8.1. +## [Unreleased] + +### Fixed + +- **The scan no longer opens LM Studio's window on Linux.** `lm-studio` names the desktop application's launcher, not a CLI (LM Studio's CLI is a separate binary, `lms`), and a packaged Electron app does not implement `--version`, so the flag was ignored and the app booted. An Ubuntu 22.04 customer running the agent from a systemd timer had LM Studio appear on their desktop mid-scan, with the probe of `/usr/bin/lm-studio` sitting on the full 10s exec deadline before being killed. Framework specs now carry a per-tool `GUIApp` flag that suppresses the `--version` fallback, so a GUI entry point is reported as installed with whatever on-disk metadata yields and `unknown` otherwise. The flag is opt-in per entry: ollama, LocalAI and Text Generation WebUI are real CLIs and are still exec'd exactly as before. +- **IDE version probes on Linux are static-first and shim-only.** `/` was an exec candidate ahead of `product-info.json` and `.eclipseproduct` — and for every VS Code fork that path is the Electron GUI binary, not the CLI (`/opt/Cursor/cursor` launches Cursor; the CLI is `bin/cursor`), so an install whose `package.json` had moved would launch the app. It is no longer a candidate. Separately, an IDE found only as a name on `$PATH` went straight to ` --version`; it now resolves the symlink and walks up to the install root's `package.json`/`product-info.json` first, which also yields a better version than the shim prints. + +### Added + +- **`execguard` now answers on Linux, not just macOS.** It was a macOS-only gate, so every Linux exec fallback ran unguarded. On Linux it now refuses a binary that is a packaged Electron app's entry point, decided from stats alone: an Electron bundle ships `resources/app.asar` and the Chromium runtime beside its executable, and nothing else does. Only the binary's own directory is examined, which is what separates the app from its CLI — `/usr/share/code/code` sits beside `libffmpeg.so` and is refused, while the shim at `/usr/share/code/bin/code` does not and is allowed. Checking the parent too, as the macOS quarantine probe must because cask installs mark whole trees, would have rejected exactly the shims we need. Electron-only is deliberate: a GTK or Qt app on `$PATH` would need its own signal. macOS and Windows behavior are unchanged. The IDE detector now consults the guard too — it was the one version-probe path that never did. +- **Three static version sources for Linux**, so fewer tools reach an exec at all. **dpkg**: the version of the package that owns the binary, proved from the package's own file manifest rather than assumed from a matching name, so a stale `foo` package cannot lend its version to a hand-installed `/usr/local/bin/foo`; purged packages are rejected. **snap**: the `version` field of `/snap//current/meta/snap.yaml`, which no path rule could reach since `/snap/bin/` symlinks to the snap wrapper. **AppImage**: the version in the filename, the only static source a single-file install has; the tool-name prefix must match. All three are plain file reads, mirroring the existing static pacman-database reads. + ## [1.16.0] - 2026-08-20 ### Added diff --git a/SCAN_COVERAGE.md b/SCAN_COVERAGE.md index 93f02008..341b229a 100644 --- a/SCAN_COVERAGE.md +++ b/SCAN_COVERAGE.md @@ -76,9 +76,26 @@ Binaries are found via `$PATH` lookup (cross-platform). LM Studio is additionall |-----------------------|------------|---------------------------------------------------------------------------------| | Ollama | `ollama` | Checks if process is running | | LocalAI | `local-ai` | Checks if process is running | -| LM Studio | `lm-studio`| GUI: `/Applications/LM Studio.app` (macOS) or `%LOCALAPPDATA%\Programs\LM Studio` (Windows) | +| LM Studio | `lm-studio`| GUI: `/Applications/LM Studio.app` (macOS), `%LOCALAPPDATA%\Programs\LM Studio` (Windows), `~/.local/share/LM Studio` or `/opt/LM Studio` (Linux). Never executed for its version — see below | | Text Generation WebUI | `textgen` | Checks if process is running | +### Version probes never launch a desktop app + +`lm-studio` names the desktop application's launcher, not a CLI (LM Studio's CLI is a +separate binary, `lms`). A packaged Electron app does not implement `--version`, so probing +it that way opens the app's window instead of printing a version. Its version therefore comes +only from on-disk metadata — the macOS bundle `Info.plist`, the Windows uninstall registry, or +on Linux the dpkg entry for the `.deb`, the snap manifest, or the version in an AppImage +filename — and reads `unknown` when none of those resolve. The tool is still reported as +installed either way. + +This is also enforced generically: before any version probe execs a binary, the agent checks +whether it is a packaged Electron app's entry point, which is visible on disk +(`resources/app.asar` and the Chromium runtime sit beside the executable; a CLI shim's +directory holds neither). On macOS the equivalent check is Gatekeeper quarantine assessment. +A refused probe reports `unknown`. Only Electron apps are detected — a GTK or Qt application +on `$PATH` is not. + ## MCP Configuration Sources On Windows, `~` refers to the user's home directory (`%USERPROFILE%`). Claude Desktop uses a Windows-specific path via `%APPDATA%`. diff --git a/internal/detector/framework.go b/internal/detector/framework.go index df79d5b1..df91cc71 100644 --- a/internal/detector/framework.go +++ b/internal/detector/framework.go @@ -16,13 +16,24 @@ type frameworkSpec struct { Name string BinaryName string ProcessName string + + // GUIApp marks a binary that is the desktop app itself rather than a CLI, + // suppressing the --version exec fallback in getVersion: with no on-disk + // source the tool reports "unknown" instead of being launched. + // + // A packaged Electron app does not implement --version (only the + // unpackaged `electron` binary's default_app does), so the flag is ignored + // and the app boots — reported by an Ubuntu 22.04 customer whose scan + // opened LM Studio's window. + GUIApp bool } var frameworkDefinitions = []frameworkSpec{ - {"ollama", "ollama", "ollama"}, - {"localai", "local-ai", "local-ai"}, - {"lm-studio", "lm-studio", "lm-studio"}, - {"text-generation-webui", "textgen", "textgen"}, + {Name: "ollama", BinaryName: "ollama", ProcessName: "ollama"}, + {Name: "localai", BinaryName: "local-ai", ProcessName: "local-ai"}, + // `lm-studio` is the desktop app's launcher; the CLI is a separate binary, `lms`. + {Name: "lm-studio", BinaryName: "lm-studio", ProcessName: "lm-studio", GUIApp: true}, + {Name: "text-generation-webui", BinaryName: "textgen", ProcessName: "textgen"}, } // FrameworkDetector detects AI frameworks and runtimes. @@ -53,7 +64,7 @@ func (d *FrameworkDetector) Detect(ctx context.Context) []model.AITool { continue } - version := d.getVersion(ctx, binaryPath) + version := d.getVersion(ctx, spec, binaryPath) isRunning := isProcessRunning(ctx, d.exec, spec.ProcessName) results = append(results, model.AITool{ @@ -84,13 +95,19 @@ func (d *FrameworkDetector) Detect(ctx context.Context) []model.AITool { return results } -func (d *FrameworkDetector) getVersion(ctx context.Context, binaryPath string) string { +func (d *FrameworkDetector) getVersion(ctx context.Context, spec frameworkSpec, binaryPath string) string { // Static-first, exec-last (AGENTS.md §3.4). Bonus: skipping exec also // avoids the daemon-warning-decorated output some frameworks (ollama) // prepend to --version. if v := versionmeta.FromBinary(ctx, d.exec, binaryPath); v != "" { return v } + // No exec step for a GUI app: "unknown" is the floor (§3.4), and the tool + // is still reported as installed. + if spec.GUIApp { + d.log.Debug("skipping %s version probe: GUI application, --version would launch it", binaryPath) + return "unknown" + } if !execguard.SafeToExec(ctx, d.exec, binaryPath) { d.log.Warn("skipping %s version probe: quarantined and rejected by Gatekeeper", binaryPath) return "unknown" @@ -124,6 +141,9 @@ func (d *FrameworkDetector) detectLMStudioApp(ctx context.Context) (model.AITool homeDir := getHomeDir(d.exec) for _, candidate := range []string{ filepath.Join(homeDir, ".local", "share", "LM Studio"), + // electron-builder's .deb installs to /opt/; the + // lowercase path is what community repackagings use. + "/opt/LM Studio", "/opt/lm-studio", } { if d.exec.DirExists(candidate) { diff --git a/internal/detector/framework_test.go b/internal/detector/framework_test.go index dd0396e6..3bb47ca6 100644 --- a/internal/detector/framework_test.go +++ b/internal/detector/framework_test.go @@ -3,8 +3,10 @@ package detector import ( "context" "testing" + "time" "github.com/step-security/dev-machine-guard/internal/executor" + "github.com/step-security/dev-machine-guard/internal/model" ) func TestFrameworkDetector_FindsOllama(t *testing.T) { @@ -146,3 +148,97 @@ func TestFrameworkDetector_Windows_FindsOllama(t *testing.T) { t.Error("ollama not found") } } + +// noExecMock turns any subprocess into a test failure. On Linux the whole +// lm-studio path (LookPath, version, /proc liveness) is filesystem reads, so +// a single exec is the regression. +type noExecMock struct { + *executor.Mock + t *testing.T +} + +func (m *noExecMock) Run(_ context.Context, name string, args ...string) (string, string, int, error) { + m.t.Fatalf("unexpected exec: %s %v", name, args) + return "", "", -1, nil +} + +func (m *noExecMock) RunWithTimeout(ctx context.Context, _ time.Duration, name string, args ...string) (string, string, int, error) { + return m.Run(ctx, name, args...) //nolint:contextcheck // trap, never reaches a real command +} + +// The reported machine: the .deb's launcher on PATH, symlinked into the +// electron-builder install root. +func linuxLMStudioMock(t *testing.T) *noExecMock { + t.Helper() + mock := executor.NewMock() + mock.SetGOOS("linux") + mock.SetHomeDir("/home/dev") + mock.SetPath("lm-studio", "/usr/bin/lm-studio") + mock.SetSymlink("/usr/bin/lm-studio", "/opt/LM Studio/lm-studio") + return &noExecMock{Mock: mock, t: t} +} + +func findTool(results []model.AITool, name string) (model.AITool, bool) { + for _, r := range results { + if r.Name == name { + return r, true + } + } + return model.AITool{}, false +} + +func TestFrameworkDetector_LMStudioLinuxIsNeverLaunched(t *testing.T) { + mock := linuxLMStudioMock(t) + + results := NewFrameworkDetector(mock).Detect(context.Background()) + + tool, ok := findTool(results, "lm-studio") + if !ok { + t.Fatal("suppressing the exec must not suppress the detection") + } + if tool.Version != "unknown" { + t.Errorf("version = %q, want unknown", tool.Version) + } + if tool.BinaryPath != "/usr/bin/lm-studio" { + t.Errorf("binary_path = %q, want /usr/bin/lm-studio", tool.BinaryPath) + } +} + +// Still recoverable without launching anything: dpkg records both the file +// list and the version of the .deb that installed the launcher. +func TestFrameworkDetector_LMStudioLinuxVersionFromDpkg(t *testing.T) { + mock := linuxLMStudioMock(t) + mock.SetFile("/var/lib/dpkg/info/lm-studio.list", []byte( + "/opt\n/opt/LM Studio\n/opt/LM Studio/lm-studio\n/usr/bin/lm-studio\n")) + mock.SetFile("/var/lib/dpkg/status", []byte( + "Package: lm-studio\nStatus: install ok installed\nVersion: 0.3.31-1\nArchitecture: amd64\n\n")) + + results := NewFrameworkDetector(mock).Detect(context.Background()) + + tool, ok := findTool(results, "lm-studio") + if !ok { + t.Fatal("lm-studio not found") + } + if tool.Version != "0.3.31" { + t.Errorf("version = %q, want 0.3.31 (upstream part of 0.3.31-1)", tool.Version) + } +} + +// GUIApp is opt-in per entry: ollama is a real CLI and must still be exec'd. +func TestFrameworkDetector_OllamaStillExecsOnLinux(t *testing.T) { + mock := executor.NewMock() + mock.SetGOOS("linux") + mock.SetHomeDir("/home/dev") + mock.SetPath("ollama", "/usr/local/bin/ollama") + mock.SetCommand("ollama version is 0.5.13\n", "", 0, "/usr/local/bin/ollama", "--version") + + results := NewFrameworkDetector(mock).Detect(context.Background()) + + tool, ok := findTool(results, "ollama") + if !ok { + t.Fatal("ollama not found") + } + if tool.Version != "0.5.13" { + t.Errorf("version = %q, want 0.5.13", tool.Version) + } +} diff --git a/internal/detector/ide.go b/internal/detector/ide.go index 22dea6de..d4d36dc4 100644 --- a/internal/detector/ide.go +++ b/internal/detector/ide.go @@ -7,6 +7,7 @@ import ( "strings" "time" + "github.com/step-security/dev-machine-guard/internal/execguard" "github.com/step-security/dev-machine-guard/internal/executor" "github.com/step-security/dev-machine-guard/internal/model" ) @@ -336,10 +337,7 @@ func (d *IDEDetector) detectLinux(ctx context.Context, spec ideSpec) (model.IDE, if spec.LinuxBinary != "" { binPath, err := d.exec.LookPath(spec.LinuxBinary) if err == nil { - version := "unknown" - if spec.VersionFlag != "" { - version = runVersionCmd(ctx, d.exec, binPath, spec.VersionFlag) - } + version := d.resolveLinuxVersionFromBinary(ctx, spec, binPath) return model.IDE{ IDEType: spec.IDEType, Version: version, InstallPath: binPath, Vendor: spec.Vendor, IsInstalled: true, @@ -377,17 +375,24 @@ func (d *IDEDetector) resolveLinuxVersion(ctx context.Context, spec ideSpec, ins return v } + // product-info.json at the root of the install dir (JetBrains, some Electron apps) + if v := readJSONVersion(d.exec, filepath.Join(installDir, "product-info.json")); v != "unknown" { + return v + } + + // .eclipseproduct at the root (Eclipse) + if v := readEclipseProductVersion(d.exec, filepath.Join(installDir, ".eclipseproduct")); v != "unknown" { + return v + } + + // Exec last, and only the CLI shim. `/` is the + // Electron GUI binary for every VS Code fork (/opt/Cursor/cursor launches + // Cursor; the CLI is bin/cursor), so it is no longer a candidate. if spec.LinuxBinary != "" && spec.VersionFlag != "" { - // Try binary inside the detected install directory first - for _, relBin := range []string{ - filepath.Join("bin", spec.LinuxBinary), - spec.LinuxBinary, - } { - localBin := filepath.Join(installDir, relBin) - if d.exec.FileExists(localBin) { - if v := runVersionCmd(ctx, d.exec, localBin, spec.VersionFlag); v != "unknown" { - return v - } + localBin := filepath.Join(installDir, "bin", spec.LinuxBinary) + if d.exec.FileExists(localBin) { + if v := runVersionCmd(ctx, d.exec, localBin, spec.VersionFlag); v != "unknown" { + return v } } @@ -399,17 +404,50 @@ func (d *IDEDetector) resolveLinuxVersion(ctx context.Context, spec ideSpec, ins } } - // product-info.json at the root of the install dir (JetBrains, some Electron apps) - if v := readJSONVersion(d.exec, filepath.Join(installDir, "product-info.json")); v != "unknown" { - return v - } + return "unknown" +} - // .eclipseproduct at the root (Eclipse) - if v := readEclipseProductVersion(d.exec, filepath.Join(installDir, ".eclipseproduct")); v != "unknown" { - return v +// resolveLinuxVersionFromBinary versions an IDE found only as a name on +// $PATH, where no install directory matched and so no metadata path is known +// up front. It recovers one by walking up to the install root, and execs only +// if that finds nothing — previously it went straight to ` --version`, +// the same launch-it-sight-unseen shape as the LM Studio bug. +func (d *IDEDetector) resolveLinuxVersionFromBinary(ctx context.Context, spec ideSpec, binPath string) string { + if root, ok := d.linuxInstallRootFromBinary(binPath); ok { + return d.resolveLinuxVersion(ctx, spec, root) } + if spec.VersionFlag == "" { + return "unknown" + } + return runVersionCmd(ctx, d.exec, binPath, spec.VersionFlag) +} - return "unknown" +// linuxInstallRootFromBinary walks up from a resolved binary looking for the +// directory carrying an IDE's version metadata: /usr/bin/code symlinks to +// /usr/share/code/bin/code, two levels below its resources/app/package.json. +// Bounded, so a binary outside any install tree costs a handful of stats. +func (d *IDEDetector) linuxInstallRootFromBinary(binPath string) (string, bool) { + resolved, err := d.exec.EvalSymlinks(binPath) + if err != nil || resolved == "" { + resolved = binPath + } + dir := filepath.Dir(resolved) + for i := 0; i < maxLinuxInstallRootDepth; i++ { + if dir == "" || dir == "/" || dir == "." { + return "", false + } + for _, marker := range []string{ + filepath.Join(dir, "resources", "app", "package.json"), + filepath.Join(dir, "product-info.json"), + filepath.Join(dir, ".eclipseproduct"), + } { + if d.exec.FileExists(marker) { + return dir, true + } + } + dir = filepath.Dir(dir) + } + return "", false } func (d *IDEDetector) detectWindows(ctx context.Context, spec ideSpec) (model.IDE, bool) { @@ -553,8 +591,18 @@ func (d *IDEDetector) resolveInstallDir(resolved string) (string, bool) { return newest, true } +// maxLinuxInstallRootDepth covers the deepest real layout +// (/usr/share/code/bin/code -> /usr/share/code is two, Toolbox one more). +const maxLinuxInstallRootDepth = 3 + // runVersionCmd runs a binary with a version flag and extracts the first line. +// Guarded by execguard — every spec in this table names a desktop application, +// so on Linux it refuses an Electron app's entry point and on macOS a +// Gatekeeper-rejected quarantined binary. func runVersionCmd(ctx context.Context, exec executor.Executor, binary, flag string) string { + if !execguard.SafeToExec(ctx, exec, binary) { + return "unknown" + } stdout, _, _, err := exec.RunWithTimeout(ctx, 10*time.Second, binary, flag) if err != nil { return "unknown" diff --git a/internal/detector/ide_test.go b/internal/detector/ide_test.go index 38537d60..cc4d0bbb 100644 --- a/internal/detector/ide_test.go +++ b/internal/detector/ide_test.go @@ -907,3 +907,121 @@ func findIDE(results []model.IDE, ideType string) *model.IDE { } return nil } + +// The install dir's bare `` is the Electron GUI binary for every +// VS Code fork, and used to be an exec candidate ahead of product-info.json. +// Here package.json is absent and only the GUI binary exists. +func TestIDEDetector_Linux_NeverExecsTheInstallDirGUIBinary(t *testing.T) { + mock := executor.NewMock() + mock.SetGOOS("linux") + installDir := "/opt/Windsurf" + mock.SetDir(installDir) + // No resources/app/package.json, no product-info.json, no .eclipseproduct. + gui := installDir + "/windsurf" + mock.SetFile(gui, []byte{}) + mock.SetCommand("9.9.9-GUI-LAUNCHED", "", 0, gui, "--version") + + found := findIDE(NewIDEDetector(mock).Detect(context.Background()), "windsurf") + if found == nil { + t.Fatal("expected Windsurf to be detected at /opt/Windsurf") + } + if found.Version == "9.9.9-GUI-LAUNCHED" { + t.Fatalf("exec'd %s — the install dir's bare binary is the GUI app, never a version target", gui) + } + if found.Version != "unknown" { + t.Errorf("version = %q, want unknown (no metadata source, no safe exec target)", found.Version) + } +} + +// Phase 2 (a $PATH hit with no matching install dir) used to go straight to +// ` --version`. The shim knows its own install root: /usr/local/bin/code +// resolves into /bin/code, two levels below the package.json. +func TestIDEDetector_Linux_PathFallbackReadsInstallRootBeforeExec(t *testing.T) { + mock := executor.NewMock() + mock.SetGOOS("linux") + // Non-standard root, so Phase 1 can't match and Phase 2 is the only way in. + root := "/opt/vscode-custom" + mock.SetPath("code", "/usr/local/bin/code") + mock.SetSymlink("/usr/local/bin/code", root+"/bin/code") + mock.SetFile(root+"/resources/app/package.json", []byte(`{"name":"code","version":"1.98.2"}`)) + // Poison both exec candidates: reaching either means the walk didn't run. + for _, b := range []string{"/usr/local/bin/code", root + "/bin/code"} { + mock.SetFile(b, []byte{}) + mock.SetCommand("9.9.9-EXEC'D", "", 0, b, "--version") + } + + found := findIDE(NewIDEDetector(mock).Detect(context.Background()), "vscode") + if found == nil { + t.Fatal("expected VS Code to be detected via the PATH fallback") + } + if found.Version == "9.9.9-EXEC'D" { + t.Fatal("Phase 2 exec'd the PATH binary before walking up to its install root") + } + if found.Version != "1.98.2" { + t.Errorf("version should come from the install root's package.json (1.98.2), got %s", found.Version) + } +} + +// The walk is best effort: a binary outside any recognizable install tree +// still resolves through the exec fallback, costing no detection. +func TestIDEDetector_Linux_PathFallbackStillExecsWhenNoRootFound(t *testing.T) { + mock := executor.NewMock() + mock.SetGOOS("linux") + mock.SetPath("code", "/usr/local/bin/code") + mock.SetFile("/usr/local/bin/code", []byte{}) + mock.SetCommand("1.98.2\nabcdef\nx64\n", "", 0, "/usr/local/bin/code", "--version") + + found := findIDE(NewIDEDetector(mock).Detect(context.Background()), "vscode") + if found == nil { + t.Fatal("expected VS Code to be detected via the PATH fallback") + } + if found.Version != "1.98.2" { + t.Errorf("version = %q, want 1.98.2 from the exec fallback", found.Version) + } +} + +// When no metadata resolves and the only exec candidate is an Electron app's +// entry point, execguard refuses it. An asar-packed app has no unpacked +// package.json, so this is the realistic shape of "static sources all missed". +func TestIDEDetector_Linux_ExecguardRefusesElectronEntryPoint(t *testing.T) { + mock := executor.NewMock() + mock.SetGOOS("linux") + root := "/opt/windsurf-custom" + mock.SetPath("windsurf", root+"/windsurf") + mock.SetFile(root+"/resources/app.asar", []byte{}) + mock.SetFile(root+"/libffmpeg.so", []byte{}) + mock.SetFile(root+"/windsurf", []byte{}) + mock.SetCommand("9.9.9-GUI-LAUNCHED", "", 0, root+"/windsurf", "--version") + + found := findIDE(NewIDEDetector(mock).Detect(context.Background()), "windsurf") + if found == nil { + t.Fatal("expected Windsurf to be detected via the PATH fallback") + } + if found.Version == "9.9.9-GUI-LAUNCHED" { + t.Fatal("exec'd the Electron entry point — execguard must refuse it on Linux") + } + if found.Version != "unknown" { + t.Errorf("version = %q, want unknown", found.Version) + } +} + +// A CLI shim one directory below the same bundle must still be exec'd, or +// every VS Code fork on Linux silently loses its version. +func TestIDEDetector_Linux_ExecguardAllowsCLIShimInsideBundle(t *testing.T) { + mock := executor.NewMock() + mock.SetGOOS("linux") + root := "/opt/windsurf-custom" + mock.SetPath("windsurf", root+"/bin/windsurf") + mock.SetFile(root+"/resources/app.asar", []byte{}) + mock.SetFile(root+"/libffmpeg.so", []byte{}) + mock.SetFile(root+"/bin/windsurf", []byte{}) + mock.SetCommand("1.12.4\n", "", 0, root+"/bin/windsurf", "--version") + + found := findIDE(NewIDEDetector(mock).Detect(context.Background()), "windsurf") + if found == nil { + t.Fatal("expected Windsurf to be detected via the PATH fallback") + } + if found.Version != "1.12.4" { + t.Errorf("version = %q, want 1.12.4 from the CLI shim", found.Version) + } +} From 3a564527ebe8c767917f868026bc56e9d04fc65e Mon Sep 17 00:00:00 2001 From: Swarit Pandey Date: Wed, 26 Aug 2026 06:56:15 +0530 Subject: [PATCH 4/4] fix(execguard): return the refusal reason instead of assuming Gatekeeper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ten callers logged "quarantined and rejected by Gatekeeper" on any SafeToExec refusal. That was accurate while the guard was macOS-only, but every Linux refusal now claims a macOS quarantine it never checked — misleading in operator logs, and exactly the kind of string that goes stale again the next time a platform or a refusal cause is added. SafeToExec returns the reason from the point that decides it, so callers log what actually happened rather than restating a guess. ide.go discards it (it has no logger and only needs the verdict). Signed-off-by: Swarit Pandey --- internal/detector/agent.go | 4 +- internal/detector/aicli.go | 12 ++--- internal/detector/configaudit/bunfig.go | 4 +- internal/detector/configaudit/yarn.go | 4 +- internal/detector/framework.go | 4 +- internal/detector/ide.go | 2 +- internal/detector/nodepm.go | 22 +++++---- internal/detector/nodepm_fallback.go | 4 +- internal/detector/pythonpm.go | 4 +- internal/execguard/execguard.go | 24 +++++++--- internal/execguard/execguard_test.go | 63 +++++++++++++++++++++---- 11 files changed, 101 insertions(+), 46 deletions(-) diff --git a/internal/detector/agent.go b/internal/detector/agent.go index 81ae0a1e..b9efd439 100644 --- a/internal/detector/agent.go +++ b/internal/detector/agent.go @@ -143,8 +143,8 @@ func (d *AgentDetector) getVersion(ctx context.Context, binaryPath string) strin if v := versionmeta.FromBinary(ctx, d.exec, binaryPath); v != "" { return v } - if !execguard.SafeToExec(ctx, d.exec, binaryPath) { - d.log.Warn("skipping %s version probe: quarantined and rejected by Gatekeeper", binaryPath) + if safe, reason := execguard.SafeToExec(ctx, d.exec, binaryPath); !safe { + d.log.Warn("skipping %s version probe: %s", binaryPath, reason) return "unknown" } d.log.Progress("exec fallback: running %s --version (no metadata version source)", binaryPath) diff --git a/internal/detector/aicli.go b/internal/detector/aicli.go index 267f3b63..5986c186 100644 --- a/internal/detector/aicli.go +++ b/internal/detector/aicli.go @@ -93,8 +93,8 @@ var cliToolDefinitions = []cliToolSpec{ Binaries: []string{"kiro-cli", "kiro", "q"}, ConfigDirs: []string{"~/.q", "~/.kiro", "~/.aws/q"}, VerifyFunc: func(ctx context.Context, exec executor.Executor, log *progress.Logger, binary string) bool { - if !execguard.SafeToExec(ctx, exec, binary) { - log.Warn("skipping %s: quarantined and rejected by Gatekeeper — cannot verify identity", binary) + if safe, reason := execguard.SafeToExec(ctx, exec, binary); !safe { + log.Warn("skipping %s: %s — cannot verify identity", binary, reason) return false } log.Progress("exec fallback: running %s --version (amazon-q identity check)", binary) @@ -133,8 +133,8 @@ var cliToolDefinitions = []cliToolSpec{ if versionmeta.NPMPackageName(exec, binary) == "@github/copilot" { return true } - if !execguard.SafeToExec(ctx, exec, binary) { - log.Warn("skipping %s: quarantined and rejected by Gatekeeper — cannot verify identity", binary) + if safe, reason := execguard.SafeToExec(ctx, exec, binary); !safe { + log.Warn("skipping %s: %s — cannot verify identity", binary, reason) return false } log.Progress("exec fallback: running %s --version (copilot identity check)", binary) @@ -374,8 +374,8 @@ func (d *AICLIDetector) getVersion(ctx context.Context, spec cliToolSpec, binary if spec.VersionFlag != "" { flag = spec.VersionFlag } - if !execguard.SafeToExec(ctx, d.exec, binaryPath) { - d.log.Warn("skipping %s version probe: quarantined and rejected by Gatekeeper", binaryPath) + if safe, reason := execguard.SafeToExec(ctx, d.exec, binaryPath); !safe { + d.log.Warn("skipping %s version probe: %s", binaryPath, reason) return "unknown" } d.log.Progress("exec fallback: running %s %s (no metadata version source)", binaryPath, flag) diff --git a/internal/detector/configaudit/bunfig.go b/internal/detector/configaudit/bunfig.go index 79b54c2d..b08db982 100644 --- a/internal/detector/configaudit/bunfig.go +++ b/internal/detector/configaudit/bunfig.go @@ -279,8 +279,8 @@ func (d *BunDetector) bunVersion(ctx context.Context) string { if v := versionmeta.FromBinary(ctx, d.exec, path); v != "" { return v } - if !execguard.SafeToExec(ctx, d.exec, path) { - d.log.Warn("skipping %s version probe: quarantined and rejected by Gatekeeper", path) + if safe, reason := execguard.SafeToExec(ctx, d.exec, path); !safe { + d.log.Warn("skipping %s version probe: %s", path, reason) return "unknown" } target = path diff --git a/internal/detector/configaudit/yarn.go b/internal/detector/configaudit/yarn.go index c9df18aa..36ddd118 100644 --- a/internal/detector/configaudit/yarn.go +++ b/internal/detector/configaudit/yarn.go @@ -285,8 +285,8 @@ func (d *YarnDetector) yarnVersion(ctx context.Context) string { if v := versionmeta.FromBinary(ctx, d.exec, path); v != "" { return v } - if !execguard.SafeToExec(ctx, d.exec, path) { - d.log.Warn("skipping %s version probe: quarantined and rejected by Gatekeeper", path) + if safe, reason := execguard.SafeToExec(ctx, d.exec, path); !safe { + d.log.Warn("skipping %s version probe: %s", path, reason) return "unknown" } target = path diff --git a/internal/detector/framework.go b/internal/detector/framework.go index df91cc71..dd29e7c9 100644 --- a/internal/detector/framework.go +++ b/internal/detector/framework.go @@ -108,8 +108,8 @@ func (d *FrameworkDetector) getVersion(ctx context.Context, spec frameworkSpec, d.log.Debug("skipping %s version probe: GUI application, --version would launch it", binaryPath) return "unknown" } - if !execguard.SafeToExec(ctx, d.exec, binaryPath) { - d.log.Warn("skipping %s version probe: quarantined and rejected by Gatekeeper", binaryPath) + if safe, reason := execguard.SafeToExec(ctx, d.exec, binaryPath); !safe { + d.log.Warn("skipping %s version probe: %s", binaryPath, reason) return "unknown" } d.log.Progress("exec fallback: running %s --version (no metadata version source)", binaryPath) diff --git a/internal/detector/ide.go b/internal/detector/ide.go index d4d36dc4..e3b4b758 100644 --- a/internal/detector/ide.go +++ b/internal/detector/ide.go @@ -600,7 +600,7 @@ const maxLinuxInstallRootDepth = 3 // so on Linux it refuses an Electron app's entry point and on macOS a // Gatekeeper-rejected quarantined binary. func runVersionCmd(ctx context.Context, exec executor.Executor, binary, flag string) string { - if !execguard.SafeToExec(ctx, exec, binary) { + if safe, _ := execguard.SafeToExec(ctx, exec, binary); !safe { return "unknown" } stdout, _, _, err := exec.RunWithTimeout(ctx, 10*time.Second, binary, flag) diff --git a/internal/detector/nodepm.go b/internal/detector/nodepm.go index 507ffb2a..3b517eff 100644 --- a/internal/detector/nodepm.go +++ b/internal/detector/nodepm.go @@ -60,16 +60,18 @@ func (d *NodePMDetector) DetectManagers(ctx context.Context) []model.PkgManager // the version without launching anything. version = versionmeta.FromBinary(ctx, d.exec, path) } - if path != "" && version == "" && !execguard.SafeToExec(ctx, d.exec, path) { - d.log.Warn("skipping %s version probe: quarantined and rejected by Gatekeeper", path) - } else if path != "" && version == "" { - // Run the exact absolute path the guard assessed, not the bare - // name — a PATH re-resolution at exec time could pick a - // different (unassessed) binary. - d.log.Progress("exec fallback: running %s %s (no metadata version source)", path, pm.VersionCmd) - stdout, _, _, err := d.exec.RunWithTimeout(ctx, 10*time.Second, path, pm.VersionCmd) - if err == nil { - version = strings.TrimSpace(stdout) + if path != "" && version == "" { + if safe, reason := execguard.SafeToExec(ctx, d.exec, path); !safe { + d.log.Warn("skipping %s version probe: %s", path, reason) + } else { + // Run the exact absolute path the guard assessed, not the bare + // name — a PATH re-resolution at exec time could pick a + // different (unassessed) binary. + d.log.Progress("exec fallback: running %s %s (no metadata version source)", path, pm.VersionCmd) + stdout, _, _, err := d.exec.RunWithTimeout(ctx, 10*time.Second, path, pm.VersionCmd) + if err == nil { + version = strings.TrimSpace(stdout) + } } } diff --git a/internal/detector/nodepm_fallback.go b/internal/detector/nodepm_fallback.go index a1d8707e..04c94507 100644 --- a/internal/detector/nodepm_fallback.go +++ b/internal/detector/nodepm_fallback.go @@ -171,8 +171,8 @@ func runPMVersion(ctx context.Context, exec executor.Executor, log *progress.Log if v := versionmeta.FromBinary(ctx, exec, binPath); v != "" { return v } - if !execguard.SafeToExec(ctx, exec, binPath) { - log.Warn("skipping %s version probe: quarantined and rejected by Gatekeeper", binPath) + if safe, reason := execguard.SafeToExec(ctx, exec, binPath); !safe { + log.Warn("skipping %s version probe: %s", binPath, reason) return "" } log.Progress("exec fallback: running %s %s (no metadata version source)", binPath, versionCmd) diff --git a/internal/detector/pythonpm.go b/internal/detector/pythonpm.go index 41c99045..79ee1b84 100644 --- a/internal/detector/pythonpm.go +++ b/internal/detector/pythonpm.go @@ -62,8 +62,8 @@ func (d *PythonPMDetector) DetectManagers(ctx context.Context) []model.PkgManage // layouts carry the version in the install path. if v := versionmeta.FromBinary(ctx, d.exec, path); v != "" { version = v - } else if !execguard.SafeToExec(ctx, d.exec, path) { - d.log.Warn("skipping %s version probe: quarantined and rejected by Gatekeeper", path) + } else if safe, reason := execguard.SafeToExec(ctx, d.exec, path); !safe { + d.log.Warn("skipping %s version probe: %s", path, reason) } else { // Run the exact absolute path the guard assessed, not the bare // name — a PATH re-resolution at exec time could pick a diff --git a/internal/execguard/execguard.go b/internal/execguard/execguard.go index 4807c5e7..090ba948 100644 --- a/internal/execguard/execguard.go +++ b/internal/execguard/execguard.go @@ -29,7 +29,11 @@ import ( const probeTimeout = 5 * time.Second // SafeToExec reports whether launching binaryPath is safe from a -// GUI-popup perspective. Windows always returns true. +// GUI-popup perspective, and when it is not, why. Windows always returns true. +// +// The reason is returned rather than left to callers to describe: they log it, +// and the cause is platform-specific, so a hardcoded message goes stale the +// moment a platform is added. It is "" when safe. // // On macOS it resolves symlinks, then checks the binary and its containing // directory for the com.apple.quarantine attribute (cask installs quarantine @@ -45,9 +49,9 @@ const probeTimeout = 5 * time.Second // // On Linux it answers whether the binary is an Electron app's GUI entry point, // purely from stats. -func SafeToExec(ctx context.Context, exec executor.Executor, binaryPath string) bool { +func SafeToExec(ctx context.Context, exec executor.Executor, binaryPath string) (bool, string) { if binaryPath == "" { - return true + return true, "" } resolved, err := exec.EvalSymlinks(binaryPath) if err != nil || resolved == "" { @@ -56,15 +60,21 @@ func SafeToExec(ctx context.Context, exec executor.Executor, binaryPath string) switch exec.GOOS() { case model.PlatformLinux: - return !isElectronAppEntryPoint(exec, resolved) + if isElectronAppEntryPoint(exec, resolved) { + return false, "it is a packaged Electron app's entry point, which would open its window instead of printing a version" + } + return true, "" case model.PlatformDarwin: if !isQuarantined(ctx, exec, resolved) && !isQuarantined(ctx, exec, parentDir(resolved)) { - return true + return true, "" } _, _, exitCode, err := exec.RunWithTimeout(ctx, probeTimeout, "/usr/sbin/spctl", "--assess", "--type", "execute", resolved) - return err == nil && exitCode == 0 + if err == nil && exitCode == 0 { + return true, "" + } + return false, "it is quarantined and Gatekeeper rejected it" default: - return true + return true, "" } } diff --git a/internal/execguard/execguard_test.go b/internal/execguard/execguard_test.go index a04c8230..99268bf7 100644 --- a/internal/execguard/execguard_test.go +++ b/internal/execguard/execguard_test.go @@ -2,6 +2,7 @@ package execguard import ( "context" + "strings" "testing" "time" @@ -25,7 +26,7 @@ func TestSafeToExec(t *testing.T) { mock := executor.NewMock() mock.SetSymlink(binary, resolved) // xattr unstubbed -> errors -> attribute absent. - if !SafeToExec(context.Background(), mock, binary) { + if safe, _ := SafeToExec(context.Background(), mock, binary); !safe { t.Error("unquarantined binary should be safe to exec") } }) @@ -35,7 +36,7 @@ func TestSafeToExec(t *testing.T) { mock.SetSymlink(binary, resolved) quarantineStub(mock, resolved) mock.SetCommand("", "rejected", 3, "/usr/sbin/spctl", spctlArgs...) - if SafeToExec(context.Background(), mock, binary) { + if safe, _ := SafeToExec(context.Background(), mock, binary); safe { t.Error("quarantined + Gatekeeper-rejected binary must not be exec'd") } }) @@ -45,7 +46,7 @@ func TestSafeToExec(t *testing.T) { mock.SetSymlink(binary, resolved) quarantineStub(mock, resolved) mock.SetCommand("accepted", "", 0, "/usr/sbin/spctl", spctlArgs...) - if !SafeToExec(context.Background(), mock, binary) { + if safe, _ := SafeToExec(context.Background(), mock, binary); !safe { t.Error("quarantined but notarized (spctl-accepted) binary should be safe") } }) @@ -56,7 +57,7 @@ func TestSafeToExec(t *testing.T) { // Binary itself clean (partially-cleared install), containing dir quarantined. quarantineStub(mock, "/opt/homebrew/Caskroom/cursor-cli/2026.03.11") mock.SetCommand("", "rejected", 3, "/usr/sbin/spctl", spctlArgs...) - if SafeToExec(context.Background(), mock, binary) { + if safe, _ := SafeToExec(context.Background(), mock, binary); safe { t.Error("quarantined install dir must trigger assessment and reject") } }) @@ -66,7 +67,7 @@ func TestSafeToExec(t *testing.T) { mock.SetSymlink(binary, resolved) quarantineStub(mock, resolved) // spctl unstubbed -> errors -> treat as rejected. - if SafeToExec(context.Background(), mock, binary) { + if safe, _ := SafeToExec(context.Background(), mock, binary); safe { t.Error("quarantined binary with failing spctl must not be exec'd") } }) @@ -76,14 +77,14 @@ func TestSafeToExec(t *testing.T) { mock := executor.NewMock() mock.SetGOOS(goos) quarantineStub(mock, binary) - if !SafeToExec(context.Background(), mock, binary) { + if safe, _ := SafeToExec(context.Background(), mock, binary); !safe { t.Errorf("GOOS=%s: quarantine is a macOS concept; must be safe", goos) } } }) t.Run("empty path is safe", func(t *testing.T) { - if !SafeToExec(context.Background(), executor.NewMock(), "") { + if safe, _ := SafeToExec(context.Background(), executor.NewMock(), ""); !safe { t.Error("empty path should be a no-op (safe)") } }) @@ -159,7 +160,7 @@ func TestSafeToExec_Linux(t *testing.T) { if tt.symlink != "" { mock.SetSymlink(tt.binary, tt.symlink) } - if got := SafeToExec(context.Background(), mock, tt.binary); got != tt.want { + if got, _ := SafeToExec(context.Background(), mock, tt.binary); got != tt.want { t.Errorf("SafeToExec(%q) = %v, want %v", tt.binary, got, tt.want) } }) @@ -172,7 +173,7 @@ func TestSafeToExec_LinuxLaunchesNothing(t *testing.T) { mock.SetSymlink("/usr/bin/lm-studio", "/opt/LM Studio/lm-studio") trap := &trapExecutor{Mock: mock, t: t} - if SafeToExec(context.Background(), trap, "/usr/bin/lm-studio") { + if safe, _ := SafeToExec(context.Background(), trap, "/usr/bin/lm-studio"); safe { t.Error("Electron app entry point must be refused") } } @@ -196,7 +197,49 @@ func TestSafeToExec_WindowsAlwaysSafe(t *testing.T) { mock := executor.NewMock() mock.SetGOOS("windows") mock.SetFile(`C:\Program Files\LM Studio\resources\app.asar`, []byte{}) - if !SafeToExec(context.Background(), mock, `C:\Program Files\LM Studio\LM Studio.exe`) { + if safe, _ := SafeToExec(context.Background(), mock, `C:\Program Files\LM Studio\LM Studio.exe`); !safe { t.Error("Windows must be unaffected") } } + +// The refusal reason is returned rather than written at each call site, because +// ten callers log it and a hardcoded string went stale the moment Linux gained +// a verdict — every Linux refusal claimed Gatekeeper quarantine. +func TestSafeToExec_ReasonMatchesPlatform(t *testing.T) { + t.Run("linux names the Electron app, not Gatekeeper", func(t *testing.T) { + mock := linuxMock("/opt/LM Studio/resources/app.asar") + mock.SetSymlink("/usr/bin/lm-studio", "/opt/LM Studio/lm-studio") + + safe, reason := SafeToExec(context.Background(), mock, "/usr/bin/lm-studio") + if safe { + t.Fatal("expected refusal") + } + if !strings.Contains(reason, "Electron") { + t.Errorf("reason = %q, want it to name the Electron app", reason) + } + if strings.Contains(reason, "Gatekeeper") || strings.Contains(reason, "quarantine") { + t.Errorf("reason = %q, must not claim macOS quarantine on linux", reason) + } + }) + + t.Run("darwin names Gatekeeper", func(t *testing.T) { + mock := executor.NewMock() + mock.SetSymlink(binary, resolved) + quarantineStub(mock, resolved) + mock.SetCommand("", "rejected", 3, "/usr/sbin/spctl", "--assess", "--type", "execute", resolved) + + safe, reason := SafeToExec(context.Background(), mock, binary) + if safe { + t.Fatal("expected refusal") + } + if !strings.Contains(reason, "Gatekeeper") { + t.Errorf("reason = %q, want it to name Gatekeeper", reason) + } + }) + + t.Run("no reason when safe", func(t *testing.T) { + if safe, reason := SafeToExec(context.Background(), linuxMock(), "/usr/bin/ollama"); !safe || reason != "" { + t.Errorf("SafeToExec = (%v, %q), want (true, \"\")", safe, reason) + } + }) +}