Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.** `<installDir>/<LinuxBinary>` 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 `<binary> --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/<name>/current/meta/snap.yaml`, which no path rule could reach since `/snap/bin/<name>` 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
Expand Down
19 changes: 18 additions & 1 deletion SCAN_COVERAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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%`.
Expand Down
4 changes: 2 additions & 2 deletions internal/detector/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
12 changes: 6 additions & 6 deletions internal/detector/aicli.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
6 changes: 6 additions & 0 deletions internal/detector/aicli_agents_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <tool>:<arch>.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)
}
Expand Down
4 changes: 2 additions & 2 deletions internal/detector/configaudit/bunfig.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions internal/detector/configaudit/yarn.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
36 changes: 28 additions & 8 deletions internal/detector/framework.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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{
Expand Down Expand Up @@ -84,15 +95,21 @@ 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
}
if !execguard.SafeToExec(ctx, d.exec, binaryPath) {
d.log.Warn("skipping %s version probe: quarantined and rejected by Gatekeeper", binaryPath)
// 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 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)
Expand Down Expand Up @@ -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/<productName>; the
// lowercase path is what community repackagings use.
"/opt/LM Studio",
"/opt/lm-studio",
} {
if d.exec.DirExists(candidate) {
Expand Down
96 changes: 96 additions & 0 deletions internal/detector/framework_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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)
}
}
Loading
Loading