Skip to content

fix(detector): stop launching desktop apps for version probes on linux - #196

Open
swarit-stepsecurity wants to merge 5 commits into
step-security:mainfrom
swarit-stepsecurity:swarit/fix/wt/pop-ups-ubuntu
Open

fix(detector): stop launching desktop apps for version probes on linux#196
swarit-stepsecurity wants to merge 5 commits into
step-security:mainfrom
swarit-stepsecurity:swarit/fix/wt/pop-ups-ubuntu

Conversation

@swarit-stepsecurity

@swarit-stepsecurity swarit-stepsecurity commented Aug 21, 2026

Copy link
Copy Markdown
Member

Problem

[scanning] exec fallback: running /usr/bin/lm-studio --version (no metadata version source)
[scanning]   Found: lm-studio vunknown at /usr/bin/lm-studio (running: false)

lm-studio is 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 is RunWithTimeout's deadline expiring.

Changes

execguard now 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 ships resources/app.asar and 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/code sits beside libffmpeg.so and is refused, /usr/share/code/bin/code does not and is allowed. macOS and Windows are unchanged.

GUIApp flag on framework specs suppresses the --version fallback 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 of product-info.json, and for every VS Code fork that path is the Electron GUI binary (/opt/Cursor/cursor launches Cursor; the CLI is bin/cursor). And the $PATH fallback went straight to <binary> --version; it now walks up to the install root's package.json first. runVersionCmd also consults execguard now — 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

  • The guard covers Electron, not all GUI toolkits. A GTK or Qt app on $PATH would need its own signal.
  • Every fix has a test that fails without it; the dangerous paths are poisoned with sentinels.
  • make lint does 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.

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>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.SafeToExec to 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 --version execs.
  • Adjust Linux IDE detection to avoid exec’ing install-root GUI binaries (VS Code forks), prefer metadata, and walk up from $PATH shims 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 thread internal/detector/framework.go Outdated
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants