Skip to content
Draft
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
6 changes: 4 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,17 @@
.vscode/
.idea/
.claude/
.plans/

# Output files
*.log
*.html
!docs/**/*.html
!images/**/*.html

# Go build artifacts
/stepsecurity-dev-machine-guard
# Go build artifacts — never commit compiled binaries
**/stepsecurity-dev-machine-guard
*.exe
dist/

# Temporary files
Expand Down
8 changes: 7 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,17 @@ LDFLAGS := -s -w \
-X $(MODULE)/internal/buildinfo.ReleaseTag=$(TAG) \
-X $(MODULE)/internal/buildinfo.ReleaseBranch=$(BRANCH)

.PHONY: build test lint clean smoke
.PHONY: build build-windows deploy-windows test lint clean smoke

build:
go build -trimpath -ldflags "$(LDFLAGS)" -o $(BINARY) ./cmd/stepsecurity-dev-machine-guard

build-windows:
GOOS=windows GOARCH=amd64 go build -trimpath -ldflags "$(LDFLAGS)" -o $(BINARY).exe ./cmd/stepsecurity-dev-machine-guard

deploy-windows:
@bash scripts/deploy-windows.sh $(DEPLOY_ARGS)

test:
go test ./... -v -race -count=1

Expand Down
13 changes: 11 additions & 2 deletions cmd/stepsecurity-dev-machine-guard/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package main
import (
"fmt"
"os"
"runtime"

"github.com/step-security/dev-machine-guard/internal/buildinfo"
"github.com/step-security/dev-machine-guard/internal/cli"
Expand Down Expand Up @@ -83,7 +84,11 @@ func main() {
}

case "install":
fmt.Fprintf(os.Stdout, "StepSecurity Dev Machine Guard v%s\n\n", buildinfo.Version)
_, _ = fmt.Fprintf(os.Stdout, "StepSecurity Dev Machine Guard v%s\n\n", buildinfo.Version)
if runtime.GOOS == "windows" {
log.Error("Scheduled scanning is not yet supported on Windows. Use the scan command directly.")
os.Exit(1)
}
if !config.IsEnterpriseMode() {
log.Error("Enterprise configuration not found. Run '%s configure' or download the script from your StepSecurity dashboard.", os.Args[0])
os.Exit(1)
Expand All @@ -100,7 +105,11 @@ func main() {
}

case "uninstall":
fmt.Fprintf(os.Stdout, "StepSecurity Dev Machine Guard v%s\n\n", buildinfo.Version)
_, _ = fmt.Fprintf(os.Stdout, "StepSecurity Dev Machine Guard v%s\n\n", buildinfo.Version)
if runtime.GOOS == "windows" {
log.Error("Scheduled scanning is not yet supported on Windows. Use the scan command directly.")
os.Exit(1)
}
if err := launchd.Uninstall(exec, log); err != nil {
log.Error("%v", err)
os.Exit(1)
Expand Down
6 changes: 3 additions & 3 deletions internal/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -93,13 +93,13 @@ func Parse(args []string) (*Config, error) {
case arg == "--verbose":
cfg.Verbose = true
case arg == "-v" || arg == "--version" || arg == "version":
fmt.Fprintf(os.Stdout, "StepSecurity Dev Machine Guard v%s\n", buildinfo.VersionString())
_, _ = fmt.Fprintf(os.Stdout, "StepSecurity Dev Machine Guard v%s\n", buildinfo.VersionString())
os.Exit(0)
case arg == "-h" || arg == "--help" || arg == "help":
printHelp()
os.Exit(0)
default:
return nil, fmt.Errorf("Unknown option: %s\nRun '%s --help' for usage information.", arg, filepath.Base(os.Args[0]))
return nil, fmt.Errorf("unknown option: %s, run '%s --help' for usage information", arg, filepath.Base(os.Args[0]))
}
i++
}
Expand All @@ -109,7 +109,7 @@ func Parse(args []string) (*Config, error) {

func printHelp() {
name := filepath.Base(os.Args[0])
fmt.Fprintf(os.Stdout, `StepSecurity Dev Machine Guard v%s
_, _ = fmt.Fprintf(os.Stdout, `StepSecurity Dev Machine Guard v%s

Usage: %s [COMMAND] [OPTIONS]

Expand Down
22 changes: 17 additions & 5 deletions internal/detector/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package detector

import (
"context"
"path/filepath"
"regexp"
"strconv"
"strings"
Expand Down Expand Up @@ -67,7 +68,7 @@ func (d *AgentDetector) Detect(ctx context.Context, searchDirs []string) []model
func (d *AgentDetector) findAgent(spec agentSpec, homeDir string) (string, bool) {
// Check detection paths
for _, relPath := range spec.DetectionPaths {
fullPath := homeDir + "/" + relPath
fullPath := filepath.Join(homeDir, relPath)
if d.exec.DirExists(fullPath) || d.exec.FileExists(fullPath) {
return fullPath, true
}
Expand Down Expand Up @@ -103,12 +104,23 @@ func (d *AgentDetector) getVersion(ctx context.Context, spec agentSpec) string {

// detectClaudeCowork checks for Claude Cowork (a mode within Claude Desktop 0.7+).
func (d *AgentDetector) detectClaudeCowork(ctx context.Context) (model.AITool, bool) {
claudePath := "/Applications/Claude.app"
if !d.exec.DirExists(claudePath) {
return model.AITool{}, false
var claudePath, version string

if d.exec.GOOS() == "windows" {
localAppData := d.exec.Getenv("LOCALAPPDATA")
claudePath = filepath.Join(localAppData, "Programs", "Claude")
if !d.exec.DirExists(claudePath) {
return model.AITool{}, false
}
version = readRegistryVersion(ctx, d.exec, "Claude")
} else {
claudePath = "/Applications/Claude.app"
if !d.exec.DirExists(claudePath) {
return model.AITool{}, false
}
version = readPlistVersion(ctx, d.exec, filepath.Join(claudePath, "Contents", "Info.plist"))
}

version := readPlistVersion(ctx, d.exec, claudePath+"/Contents/Info.plist")
if version == "unknown" {
return model.AITool{}, false
}
Expand Down
42 changes: 42 additions & 0 deletions internal/detector/agent_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,3 +96,45 @@ func TestIsCoworkVersion(t *testing.T) {
}
}
}

func TestAgentDetector_Windows_ClaudeCowork(t *testing.T) {
mock := executor.NewMock()
mock.SetGOOS("windows")
mock.SetHomeDir(`C:\Users\testuser`)
mock.SetEnv("LOCALAPPDATA", `C:\Users\testuser\AppData\Local`)

// detectClaudeCowork on Windows uses filepath.Join(localAppData, "Programs", "Claude").
// On macOS host, filepath.Join keeps backslashes and inserts "/":
claudePath := `C:\Users\testuser\AppData\Local` + "/Programs/Claude"
mock.SetDir(claudePath)

// Version via readRegistryVersion with appName "Claude".
// First registry root tried by readRegistryVersion.
mock.SetCommand(
"HKLM\\SOFTWARE\\...\\Claude\n DisplayVersion REG_SZ 0.7.5\n",
"", 0,
"reg", "query", `HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall`, "/s", "/f", "Claude", "/d",
)

det := NewAgentDetector(mock)
results := det.Detect(context.Background(), []string{`C:\Users\testuser`})

found := false
for _, r := range results {
if r.Name == "claude-cowork" {
found = true
if r.Vendor != "Anthropic" {
t.Errorf("expected Anthropic, got %s", r.Vendor)
}
if r.Version != "0.7.5" {
t.Errorf("expected 0.7.5, got %s", r.Version)
}
if r.InstallPath != claudePath {
t.Errorf("expected install path %s, got %s", claudePath, r.InstallPath)
}
}
}
if !found {
t.Error("claude-cowork not found")
}
}
46 changes: 41 additions & 5 deletions internal/detector/aicli.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package detector

import (
"context"
"os"
"path/filepath"
"strings"
"time"

Expand Down Expand Up @@ -121,11 +123,17 @@ func (d *AICLIDetector) Detect(ctx context.Context) []model.AITool {
func (d *AICLIDetector) findBinary(ctx context.Context, spec cliToolSpec, homeDir string) (string, bool) {
for _, bin := range spec.Binaries {
expanded := expandTilde(bin, homeDir)
if strings.Contains(expanded, "/") {
// Absolute/relative path - check if it exists
if expanded != bin {
// Path was expanded from tilde — it's a home-relative path, check if it exists
if d.exec.FileExists(expanded) {
return expanded, true
}
// On Windows, also try with .exe suffix
if d.exec.GOOS() == "windows" && !strings.HasSuffix(expanded, ".exe") {
if d.exec.FileExists(expanded + ".exe") {
return expanded + ".exe", true
}
}
continue
}
// Search in PATH
Expand All @@ -149,12 +157,25 @@ func (d *AICLIDetector) getVersion(ctx context.Context, spec cliToolSpec, binary
if len(lines) > 0 {
v := strings.TrimSpace(lines[0])
if v != "" {
return v
return cleanVersionString(v)
}
}
return "unknown"
}

// cleanVersionString strips a leading tool name prefix from version output.
// e.g. "codex-cli 0.118.0" -> "0.118.0", "aider 0.86.2" -> "0.86.2"
func cleanVersionString(v string) string {
parts := strings.Fields(v)
for _, p := range parts {
trimmed := strings.TrimLeft(p, "v")
if len(trimmed) > 0 && trimmed[0] >= '0' && trimmed[0] <= '9' {
return p
}
}
return v
}

func (d *AICLIDetector) findConfigDir(spec cliToolSpec, homeDir string) string {
for _, dir := range spec.ConfigDirs {
expanded := expandTilde(dir, homeDir)
Expand All @@ -167,15 +188,30 @@ func (d *AICLIDetector) findConfigDir(spec cliToolSpec, homeDir string) string {

func expandTilde(path, homeDir string) string {
if strings.HasPrefix(path, "~/") {
return homeDir + path[1:]
return filepath.Join(homeDir, filepath.FromSlash(path[2:]))
}
return path
}

func getHomeDir(exec executor.Executor) string {
u, err := exec.CurrentUser()
if err != nil {
return "/tmp"
return os.TempDir()
}
return u.HomeDir
}

// resolveEnvPath replaces %ENVVAR% patterns in Windows-style paths using the executor.
func resolveEnvPath(exec executor.Executor, path string) string {
for strings.Contains(path, "%") {
start := strings.Index(path, "%")
end := strings.Index(path[start+1:], "%")
if end < 0 {
break
}
envName := path[start+1 : start+1+end]
envVal := exec.Getenv(envName)
path = path[:start] + envVal + path[start+2+end:]
}
return filepath.FromSlash(path)
}
5 changes: 3 additions & 2 deletions internal/detector/extension.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package detector
import (
"context"
"encoding/json"
"path/filepath"
"strings"

"github.com/step-security/dev-machine-guard/internal/executor"
Expand Down Expand Up @@ -78,7 +79,7 @@ func (d *ExtensionDetector) collectFromDir(extDir, ideType string) []model.Exten
}

// Get install date from directory modification time
info, err := d.exec.Stat(extDir + "/" + dirname)
info, err := d.exec.Stat(filepath.Join(extDir, dirname))
if err == nil {
ext.InstallDate = info.ModTime().Unix()
}
Expand Down Expand Up @@ -130,7 +131,7 @@ func parseExtensionDir(dirname, ideType string) *model.Extension {

// loadObsolete reads the .obsolete file and returns a set of dirname -> true.
func (d *ExtensionDetector) loadObsolete(extDir string) map[string]bool {
obsoleteFile := extDir + "/.obsolete"
obsoleteFile := filepath.Join(extDir, ".obsolete")
data, err := d.exec.ReadFile(obsoleteFile)
if err != nil {
return nil
Expand Down
36 changes: 19 additions & 17 deletions internal/detector/framework.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package detector

import (
"context"
"path/filepath"
"strings"
"time"

Expand Down Expand Up @@ -41,7 +42,7 @@ func (d *FrameworkDetector) Detect(ctx context.Context) []model.AITool {
}

version := d.getVersion(ctx, binaryPath)
isRunning := d.isProcessRunning(ctx, spec.ProcessName)
isRunning := isProcessRunning(ctx, d.exec, spec.ProcessName)

results = append(results, model.AITool{
Name: spec.Name,
Expand Down Expand Up @@ -86,31 +87,32 @@ func (d *FrameworkDetector) getVersion(ctx context.Context, binaryPath string) s
return "unknown"
}

func (d *FrameworkDetector) isProcessRunning(ctx context.Context, processName string) bool {
_, _, exitCode, _ := d.exec.Run(ctx, "pgrep", "-x", processName)
return exitCode == 0
}

func (d *FrameworkDetector) detectLMStudioApp(ctx context.Context) (model.AITool, bool) {
appPath := "/Applications/LM Studio.app"
if !d.exec.DirExists(appPath) {
return model.AITool{}, false
}

version := readPlistVersion(ctx, d.exec, appPath+"/Contents/Info.plist")
var appPath, version string

isRunning := false
_, _, exitCode, _ := d.exec.Run(ctx, "pgrep", "-f", "LM Studio")
if exitCode == 0 {
isRunning = true
if d.exec.GOOS() == "windows" {
localAppData := d.exec.Getenv("LOCALAPPDATA")
appPath = filepath.Join(localAppData, "Programs", "LM Studio")
if !d.exec.DirExists(appPath) {
return model.AITool{}, false
}
version = readRegistryVersion(ctx, d.exec, "LM Studio")
} else {
appPath = "/Applications/LM Studio.app"
if !d.exec.DirExists(appPath) {
return model.AITool{}, false
}
version = readPlistVersion(ctx, d.exec, filepath.Join(appPath, "Contents", "Info.plist"))
}

running := isProcessRunningFuzzy(ctx, d.exec, "LM Studio")

return model.AITool{
Name: "lm-studio",
Vendor: "LM Studio",
Type: "framework",
Version: version,
BinaryPath: appPath,
IsRunning: &isRunning,
IsRunning: &running,
}, true
}
Loading
Loading