Skip to content

Commit e8c63df

Browse files
feat: add windows support
Signed-off-by: Swarit Pandey <swarit@stepsecurity.io>
1 parent 5d93c7c commit e8c63df

28 files changed

Lines changed: 691 additions & 123 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
.vscode/
1111
.idea/
1212
.claude/
13+
.plans/
1314

1415
# Output files
1516
*.log

Makefile

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,14 @@ LDFLAGS := -s -w \
99
-X $(MODULE)/internal/buildinfo.ReleaseTag=$(TAG) \
1010
-X $(MODULE)/internal/buildinfo.ReleaseBranch=$(BRANCH)
1111

12-
.PHONY: build test lint clean smoke
12+
.PHONY: build build-windows test lint clean smoke
1313

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

17+
build-windows:
18+
GOOS=windows GOARCH=amd64 go build -trimpath -ldflags "$(LDFLAGS)" -o $(BINARY).exe ./cmd/stepsecurity-dev-machine-guard
19+
1720
test:
1821
go test ./... -v -race -count=1
1922

cmd/stepsecurity-dev-machine-guard/main.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package main
33
import (
44
"fmt"
55
"os"
6+
"runtime"
67

78
"github.com/step-security/dev-machine-guard/internal/buildinfo"
89
"github.com/step-security/dev-machine-guard/internal/cli"
@@ -84,6 +85,10 @@ func main() {
8485

8586
case "install":
8687
fmt.Fprintf(os.Stdout, "StepSecurity Dev Machine Guard v%s\n\n", buildinfo.Version)
88+
if runtime.GOOS == "windows" {
89+
log.Error("Scheduled scanning is not yet supported on Windows. Use the scan command directly.")
90+
os.Exit(1)
91+
}
8792
if !config.IsEnterpriseMode() {
8893
log.Error("Enterprise configuration not found. Run '%s configure' or download the script from your StepSecurity dashboard.", os.Args[0])
8994
os.Exit(1)
@@ -101,6 +106,10 @@ func main() {
101106

102107
case "uninstall":
103108
fmt.Fprintf(os.Stdout, "StepSecurity Dev Machine Guard v%s\n\n", buildinfo.Version)
109+
if runtime.GOOS == "windows" {
110+
log.Error("Scheduled scanning is not yet supported on Windows. Use the scan command directly.")
111+
os.Exit(1)
112+
}
104113
if err := launchd.Uninstall(exec, log); err != nil {
105114
log.Error("%v", err)
106115
os.Exit(1)

internal/detector/agent.go

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package detector
22

33
import (
44
"context"
5+
"path/filepath"
56
"regexp"
67
"strconv"
78
"strings"
@@ -67,7 +68,7 @@ func (d *AgentDetector) Detect(ctx context.Context, searchDirs []string) []model
6768
func (d *AgentDetector) findAgent(spec agentSpec, homeDir string) (string, bool) {
6869
// Check detection paths
6970
for _, relPath := range spec.DetectionPaths {
70-
fullPath := homeDir + "/" + relPath
71+
fullPath := filepath.Join(homeDir, relPath)
7172
if d.exec.DirExists(fullPath) || d.exec.FileExists(fullPath) {
7273
return fullPath, true
7374
}
@@ -103,12 +104,23 @@ func (d *AgentDetector) getVersion(ctx context.Context, spec agentSpec) string {
103104

104105
// detectClaudeCowork checks for Claude Cowork (a mode within Claude Desktop 0.7+).
105106
func (d *AgentDetector) detectClaudeCowork(ctx context.Context) (model.AITool, bool) {
106-
claudePath := "/Applications/Claude.app"
107-
if !d.exec.DirExists(claudePath) {
108-
return model.AITool{}, false
107+
var claudePath, version string
108+
109+
if d.exec.GOOS() == "windows" {
110+
localAppData := d.exec.Getenv("LOCALAPPDATA")
111+
claudePath = filepath.Join(localAppData, "Programs", "Claude")
112+
if !d.exec.DirExists(claudePath) {
113+
return model.AITool{}, false
114+
}
115+
version = readRegistryVersion(ctx, d.exec, "Claude")
116+
} else {
117+
claudePath = "/Applications/Claude.app"
118+
if !d.exec.DirExists(claudePath) {
119+
return model.AITool{}, false
120+
}
121+
version = readPlistVersion(ctx, d.exec, filepath.Join(claudePath, "Contents", "Info.plist"))
109122
}
110123

111-
version := readPlistVersion(ctx, d.exec, claudePath+"/Contents/Info.plist")
112124
if version == "unknown" {
113125
return model.AITool{}, false
114126
}

internal/detector/agent_test.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,3 +96,45 @@ func TestIsCoworkVersion(t *testing.T) {
9696
}
9797
}
9898
}
99+
100+
func TestAgentDetector_Windows_ClaudeCowork(t *testing.T) {
101+
mock := executor.NewMock()
102+
mock.SetGOOS("windows")
103+
mock.SetHomeDir(`C:\Users\testuser`)
104+
mock.SetEnv("LOCALAPPDATA", `C:\Users\testuser\AppData\Local`)
105+
106+
// detectClaudeCowork on Windows uses filepath.Join(localAppData, "Programs", "Claude").
107+
// On macOS host, filepath.Join keeps backslashes and inserts "/":
108+
claudePath := `C:\Users\testuser\AppData\Local` + "/Programs/Claude"
109+
mock.SetDir(claudePath)
110+
111+
// Version via readRegistryVersion with appName "Claude".
112+
// First registry root tried by readRegistryVersion.
113+
mock.SetCommand(
114+
"HKLM\\SOFTWARE\\...\\Claude\n DisplayVersion REG_SZ 0.7.5\n",
115+
"", 0,
116+
"reg", "query", `HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall`, "/s", "/f", "Claude", "/d",
117+
)
118+
119+
det := NewAgentDetector(mock)
120+
results := det.Detect(context.Background(), []string{`C:\Users\testuser`})
121+
122+
found := false
123+
for _, r := range results {
124+
if r.Name == "claude-cowork" {
125+
found = true
126+
if r.Vendor != "Anthropic" {
127+
t.Errorf("expected Anthropic, got %s", r.Vendor)
128+
}
129+
if r.Version != "0.7.5" {
130+
t.Errorf("expected 0.7.5, got %s", r.Version)
131+
}
132+
if r.InstallPath != claudePath {
133+
t.Errorf("expected install path %s, got %s", claudePath, r.InstallPath)
134+
}
135+
}
136+
}
137+
if !found {
138+
t.Error("claude-cowork not found")
139+
}
140+
}

internal/detector/aicli.go

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ package detector
22

33
import (
44
"context"
5+
"os"
6+
"path/filepath"
57
"strings"
68
"time"
79

@@ -121,11 +123,17 @@ func (d *AICLIDetector) Detect(ctx context.Context) []model.AITool {
121123
func (d *AICLIDetector) findBinary(ctx context.Context, spec cliToolSpec, homeDir string) (string, bool) {
122124
for _, bin := range spec.Binaries {
123125
expanded := expandTilde(bin, homeDir)
124-
if strings.Contains(expanded, "/") {
125-
// Absolute/relative path - check if it exists
126+
if expanded != bin {
127+
// Path was expanded from tilde — it's a home-relative path, check if it exists
126128
if d.exec.FileExists(expanded) {
127129
return expanded, true
128130
}
131+
// On Windows, also try with .exe suffix
132+
if d.exec.GOOS() == "windows" && !strings.HasSuffix(expanded, ".exe") {
133+
if d.exec.FileExists(expanded + ".exe") {
134+
return expanded + ".exe", true
135+
}
136+
}
129137
continue
130138
}
131139
// Search in PATH
@@ -167,15 +175,30 @@ func (d *AICLIDetector) findConfigDir(spec cliToolSpec, homeDir string) string {
167175

168176
func expandTilde(path, homeDir string) string {
169177
if strings.HasPrefix(path, "~/") {
170-
return homeDir + path[1:]
178+
return filepath.Join(homeDir, filepath.FromSlash(path[2:]))
171179
}
172180
return path
173181
}
174182

175183
func getHomeDir(exec executor.Executor) string {
176184
u, err := exec.CurrentUser()
177185
if err != nil {
178-
return "/tmp"
186+
return os.TempDir()
179187
}
180188
return u.HomeDir
181189
}
190+
191+
// resolveEnvPath replaces %ENVVAR% patterns in Windows-style paths using the executor.
192+
func resolveEnvPath(exec executor.Executor, path string) string {
193+
for strings.Contains(path, "%") {
194+
start := strings.Index(path, "%")
195+
end := strings.Index(path[start+1:], "%")
196+
if end < 0 {
197+
break
198+
}
199+
envName := path[start+1 : start+1+end]
200+
envVal := exec.Getenv(envName)
201+
path = path[:start] + envVal + path[start+2+end:]
202+
}
203+
return filepath.FromSlash(path)
204+
}

internal/detector/extension.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package detector
33
import (
44
"context"
55
"encoding/json"
6+
"path/filepath"
67
"strings"
78

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

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

131132
// loadObsolete reads the .obsolete file and returns a set of dirname -> true.
132133
func (d *ExtensionDetector) loadObsolete(extDir string) map[string]bool {
133-
obsoleteFile := extDir + "/.obsolete"
134+
obsoleteFile := filepath.Join(extDir, ".obsolete")
134135
data, err := d.exec.ReadFile(obsoleteFile)
135136
if err != nil {
136137
return nil

internal/detector/framework.go

Lines changed: 19 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package detector
22

33
import (
44
"context"
5+
"path/filepath"
56
"strings"
67
"time"
78

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

4344
version := d.getVersion(ctx, binaryPath)
44-
isRunning := d.isProcessRunning(ctx, spec.ProcessName)
45+
isRunning := isProcessRunning(ctx, d.exec, spec.ProcessName)
4546

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

89-
func (d *FrameworkDetector) isProcessRunning(ctx context.Context, processName string) bool {
90-
_, _, exitCode, _ := d.exec.Run(ctx, "pgrep", "-x", processName)
91-
return exitCode == 0
92-
}
93-
9490
func (d *FrameworkDetector) detectLMStudioApp(ctx context.Context) (model.AITool, bool) {
95-
appPath := "/Applications/LM Studio.app"
96-
if !d.exec.DirExists(appPath) {
97-
return model.AITool{}, false
98-
}
99-
100-
version := readPlistVersion(ctx, d.exec, appPath+"/Contents/Info.plist")
91+
var appPath, version string
10192

102-
isRunning := false
103-
_, _, exitCode, _ := d.exec.Run(ctx, "pgrep", "-f", "LM Studio")
104-
if exitCode == 0 {
105-
isRunning = true
93+
if d.exec.GOOS() == "windows" {
94+
localAppData := d.exec.Getenv("LOCALAPPDATA")
95+
appPath = filepath.Join(localAppData, "Programs", "LM Studio")
96+
if !d.exec.DirExists(appPath) {
97+
return model.AITool{}, false
98+
}
99+
version = readRegistryVersion(ctx, d.exec, "LM Studio")
100+
} else {
101+
appPath = "/Applications/LM Studio.app"
102+
if !d.exec.DirExists(appPath) {
103+
return model.AITool{}, false
104+
}
105+
version = readPlistVersion(ctx, d.exec, filepath.Join(appPath, "Contents", "Info.plist"))
106106
}
107107

108+
running := isProcessRunningFuzzy(ctx, d.exec, "LM Studio")
109+
108110
return model.AITool{
109111
Name: "lm-studio",
110112
Vendor: "LM Studio",
111113
Type: "framework",
112114
Version: version,
113115
BinaryPath: appPath,
114-
IsRunning: &isRunning,
116+
IsRunning: &running,
115117
}, true
116118
}

internal/detector/framework_test.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,3 +74,45 @@ func TestFrameworkDetector_LMStudioApp(t *testing.T) {
7474
t.Error("lm-studio not found")
7575
}
7676
}
77+
78+
func TestFrameworkDetector_Windows_FindsOllama(t *testing.T) {
79+
mock := executor.NewMock()
80+
mock.SetGOOS("windows")
81+
mock.SetPath("ollama", `C:\Program Files\Ollama\ollama.exe`)
82+
83+
mock.SetCommand("0.5.4\n", "", 0, `C:\Program Files\Ollama\ollama.exe`, "--version")
84+
85+
// isProcessRunning on Windows: tasklist /FI "IMAGENAME eq ollama.exe" /NH
86+
mock.SetCommand(
87+
"ollama.exe 12345 Console 1 100,000 K\n",
88+
"", 0,
89+
"tasklist", "/FI", "IMAGENAME eq ollama.exe", "/NH",
90+
)
91+
92+
// LM Studio app detection on Windows also runs; ensure it doesn't interfere.
93+
// detectLMStudioApp will try Getenv("LOCALAPPDATA") which is empty, so DirExists will fail.
94+
// isProcessRunningFuzzy on Windows calls tasklist /NH
95+
mock.SetCommand("", "", 1, "tasklist", "/NH")
96+
97+
det := NewFrameworkDetector(mock)
98+
results := det.Detect(context.Background())
99+
100+
found := false
101+
for _, r := range results {
102+
if r.Name == "ollama" {
103+
found = true
104+
if r.Type != "framework" {
105+
t.Errorf("expected framework, got %s", r.Type)
106+
}
107+
if r.Version != "0.5.4" {
108+
t.Errorf("expected 0.5.4, got %s", r.Version)
109+
}
110+
if r.IsRunning == nil || !*r.IsRunning {
111+
t.Error("expected is_running=true")
112+
}
113+
}
114+
}
115+
if !found {
116+
t.Error("ollama not found")
117+
}
118+
}

0 commit comments

Comments
 (0)