fix(detector): stop launching desktop apps for version probes on linux - #196
Open
swarit-stepsecurity wants to merge 5 commits into
Open
fix(detector): stop launching desktop apps for version probes on linux#196swarit-stepsecurity wants to merge 5 commits into
swarit-stepsecurity wants to merge 5 commits into
Conversation
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 `<pkg>:<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/<name>/current/meta/snap.yaml. No path rule could reach this — /snap/bin/<name> 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 <snap>.<app>` 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 <swarit@stepsecurity.io>
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 <swarit@stepsecurity.io>
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 <installDir>/<LinuxBinary> 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 `<binary> --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 <swarit@stepsecurity.io>
swarit-stepsecurity
force-pushed
the
swarit/fix/wt/pop-ups-ubuntu
branch
from
August 21, 2026 04:57
798daf3 to
671575d
Compare
There was a problem hiding this comment.
Pull request overview
This PR prevents Linux version-probe fallbacks from launching packaged Electron desktop apps (notably LM Studio) by extending execguard to detect Electron GUI entry points, suppressing exec fallbacks for known GUI-only binaries, and improving static Linux version resolution.
Changes:
- Extend
execguard.SafeToExecto refuse Electron GUI entry points on Linux using on-disk bundle markers, and wire that guard into IDE version probing. - Add Linux static version sources in
versionmeta(dpkg ownership, snap manifest, AppImage filename) to reduce reliance on--versionexecs. - Adjust Linux IDE detection to avoid exec’ing install-root GUI binaries (VS Code forks), prefer metadata, and walk up from
$PATHshims to find install-root metadata.
Reviewed changes
Copilot reviewed 12 out of 12 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
SCAN_COVERAGE.md |
Documents LM Studio Linux locations and the “never launch desktop app” version-probe policy. |
CHANGELOG.md |
Adds Unreleased notes describing the Linux GUI-launch prevention and new version sources. |
internal/versionmeta/versionmeta.go |
Adds Linux version-source ordering (dpkg/snap/AppImage) to FromBinary. |
internal/versionmeta/linux.go |
Implements Linux static version extraction from dpkg, snap, and AppImage metadata. |
internal/versionmeta/linux_test.go |
Adds unit tests covering dpkg/snap/AppImage version extraction behavior and edge cases. |
internal/execguard/execguard.go |
Extends SafeToExec to detect/refuse Electron GUI entry points on Linux. |
internal/execguard/execguard_test.go |
Adds Linux-specific tests ensuring Electron entry points are refused and no subprocess is launched. |
internal/detector/ide.go |
Changes Linux IDE version resolution to be metadata-first, shim-only for exec, and guarded by execguard. |
internal/detector/ide_test.go |
Adds regression tests preventing exec of install-root GUI binaries and validating install-root walk behavior. |
internal/detector/framework.go |
Adds per-tool GUIApp flag to suppress exec fallback (LM Studio) and updates LM Studio Linux GUI detection paths. |
internal/detector/framework_test.go |
Adds tests ensuring LM Studio isn’t launched on Linux, dpkg version works, and non-GUI tools still exec as before. |
internal/detector/aicli_agents_test.go |
Allows dpkg-related globs introduced by versionmeta in test harness expectations. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Comment on lines
111
to
113
| if !execguard.SafeToExec(ctx, d.exec, binaryPath) { | ||
| d.log.Warn("skipping %s version probe: quarantined and rejected by Gatekeeper", binaryPath) | ||
| return "unknown" |
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 <swarit@stepsecurity.io>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
lm-studiois the desktop app's launcher, not a CLI (LM Studio's CLI is a separate binary,lms). A packaged Electron app does not implement--version, so the flag is ignored and the app boots. The 10s gap between those log lines isRunWithTimeout's deadline expiring.Changes
execguardnow answers on Linux. It was a macOS-only gate, so every Linux exec fallback ran unguarded. On Linux it refuses a binary that is a packaged Electron app's entry point: an Electron bundle shipsresources/app.asarand the Chromium runtime beside its executable, and nothing else does. Only the binary's own directory is examined, which separates the app from its CLI —/usr/share/code/codesits besidelibffmpeg.soand is refused,/usr/share/code/bin/codedoes not and is allowed. macOS and Windows are unchanged.GUIAppflag on framework specs suppresses the--versionfallback for LM Studio. Opt-in per entry — ollama, LocalAI and Text Generation WebUI are still exec'd as before.Two IDE-detector paths had the same shape.
<installDir>/<LinuxBinary>was an exec candidate ahead ofproduct-info.json, and for every VS Code fork that path is the Electron GUI binary (/opt/Cursor/cursorlaunches Cursor; the CLI isbin/cursor). And the$PATHfallback went straight to<binary> --version; it now walks up to the install root'spackage.jsonfirst.runVersionCmdalso consultsexecguardnow — it was the one version-probe path that never did.Three static Linux version sources so fewer tools reach an exec, and so LM Studio keeps a real version instead of
unknown: dpkg (ownership proved from the package's file manifest, not assumed from a matching name), snap (/snap/<name>/current/meta/snap.yaml, unreachable by path rules since/snap/bin/<name>symlinks to the snap wrapper), and AppImage (version in the filename). All plain file reads.Notes
$PATHwould need its own signal.make lintdoes not run locally — the installed golangci-lint rejects the repo config ('Version' expected a map, got 'string'), predating this branch. Worth watching CI lint here.