Skip to content
Merged
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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@ 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.

## [1.9.2] - 2026-04-15

### Fixed

- LaunchDaemon now sets `HOME` in the plist environment so `configDir()` resolves correctly at runtime (fixes "Enterprise configuration not found" error in periodic scans).
- Progress and error log lines now include timestamps for easier debugging.

## [1.9.1] - 2026-04-07

### Fixed
Expand Down Expand Up @@ -65,6 +72,7 @@ First open-source release. The scanning engine was previously an internal enterp
- Execution log capture and base64 encoding
- Instance locking to prevent concurrent runs

[1.9.2]: https://github.com/step-security/dev-machine-guard/compare/v1.9.1...v1.9.2
[1.9.1]: https://github.com/step-security/dev-machine-guard/compare/v1.9.0...v1.9.1
[1.9.0]: https://github.com/step-security/dev-machine-guard/compare/v1.8.2...v1.9.0
[1.8.2]: https://github.com/step-security/dev-machine-guard/compare/v1.8.1...v1.8.2
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
<a href="https://github.com/step-security/dev-machine-guard/actions/workflows/go.yml"><img src="https://github.com/step-security/dev-machine-guard/actions/workflows/go.yml/badge.svg" alt="Go CI"></a>
<a href="https://github.com/step-security/dev-machine-guard/actions/workflows/shellcheck.yml"><img src="https://github.com/step-security/dev-machine-guard/actions/workflows/shellcheck.yml/badge.svg" alt="ShellCheck CI"></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/license-Apache%202.0-blue.svg" alt="License: Apache 2.0"></a>
<a href="https://github.com/step-security/dev-machine-guard/releases"><img src="https://img.shields.io/badge/version-1.9.1-purple.svg" alt="Version 1.9.1"></a>
<a href="https://github.com/step-security/dev-machine-guard/releases"><img src="https://img.shields.io/badge/version-1.9.2-purple.svg" alt="Version 1.9.2"></a>
</p>

<p align="center">
Expand Down
2 changes: 1 addition & 1 deletion examples/sample-output.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"agent_version": "1.9.1",
"agent_version": "1.9.2",
"scan_timestamp": 1741305600,
"scan_timestamp_iso": "2026-03-07T00:00:00Z",
"device": {
Expand Down
2 changes: 1 addition & 1 deletion internal/buildinfo/version.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ package buildinfo
import "fmt"

const (
Version = "1.9.1"
Version = "1.9.2"
AgentURL = "https://github.com/step-security/dev-machine-guard"
)

Expand Down
10 changes: 5 additions & 5 deletions internal/detector/aicli.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,10 +54,10 @@ var cliToolDefinitions = []cliToolSpec{
},
},
{
Name: "github-copilot-cli",
Vendor: "Microsoft",
Binaries: []string{"copilot", "gh-copilot"},
ConfigDirs: []string{"~/.config/github-copilot"},
Name: "github-copilot-cli",
Vendor: "Microsoft",
Binaries: []string{"copilot", "gh-copilot"},
ConfigDirs: []string{"~/.config/github-copilot"},
},
{
Name: "microsoft-ai-shell",
Expand Down Expand Up @@ -196,7 +196,7 @@ func expandTilde(path, homeDir string) string {
}

func getHomeDir(exec executor.Executor) string {
u, err := exec.CurrentUser()
u, err := exec.LoggedInUser()
if err != nil {
return os.TempDir()
}
Expand Down
4 changes: 2 additions & 2 deletions internal/detector/ide.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,12 +53,12 @@ var ideDefinitions = []ideSpec{
},
{
AppName: "Claude", IDEType: "claude_desktop", Vendor: "Anthropic",
AppPath: "/Applications/Claude.app",
AppPath: "/Applications/Claude.app",
WinPaths: []string{`%LOCALAPPDATA%\Programs\Claude`},
},
{
AppName: "Microsoft Copilot", IDEType: "microsoft_copilot_desktop", Vendor: "Microsoft",
AppPath: "/Applications/Copilot.app",
AppPath: "/Applications/Copilot.app",
WinPaths: []string{`%LOCALAPPDATA%\Programs\Copilot`},
},
}
Expand Down
87 changes: 74 additions & 13 deletions internal/detector/nodescan.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package detector
import (
"context"
"encoding/base64"
"fmt"
"os"
"path/filepath"
"sort"
Expand Down Expand Up @@ -30,12 +31,72 @@ func getMaxProjectScanBytes() int64 {

// NodeScanner performs enterprise-mode node scanning (raw output, base64 encoded).
type NodeScanner struct {
exec executor.Executor
log *progress.Logger
exec executor.Executor
log *progress.Logger
loggedInUser string // when non-empty and running as root, commands run as this user
}

func NewNodeScanner(exec executor.Executor, log *progress.Logger) *NodeScanner {
return &NodeScanner{exec: exec, log: log}
func NewNodeScanner(exec executor.Executor, log *progress.Logger, loggedInUser string) *NodeScanner {
return &NodeScanner{exec: exec, log: log, loggedInUser: loggedInUser}
}

// shouldRunAsUser returns true when commands should be delegated to the logged-in user.
// Only applies on Unix — RunAsUser uses sudo which is not available on Windows.
func (s *NodeScanner) shouldRunAsUser() bool {
return s.exec.GOOS() != "windows" && s.exec.IsRoot() && s.loggedInUser != ""
}

// runCmd runs a command, delegating to the logged-in user when running as root.
// This ensures package manager commands use the real user's PATH and config.
func (s *NodeScanner) runCmd(ctx context.Context, timeout time.Duration, name string, args ...string) (string, string, int, error) {
if s.shouldRunAsUser() {
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
cmd := name
for _, a := range args {
cmd += " " + a
Comment thread
shubham-stepsecurity marked this conversation as resolved.
}
stdout, err := s.exec.RunAsUser(ctx, s.loggedInUser, cmd)
if err != nil {
if ctx.Err() == context.DeadlineExceeded {
return stdout, "", 124, fmt.Errorf("command timed out after %s", timeout)
}
return stdout, "", 1, err
}
return stdout, "", 0, nil
}
return s.exec.RunWithTimeout(ctx, timeout, name, args...)
}
Comment thread
shubham-stepsecurity marked this conversation as resolved.

// runShellCmd runs a shell command string, delegating to the logged-in user when running as root.
// Falls through to the platform-aware free function for the normal (non-delegation) path.
func (s *NodeScanner) runShellCmd(ctx context.Context, timeout time.Duration, shellCmd string) (string, string, int, error) {
if s.shouldRunAsUser() {
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
stdout, err := s.exec.RunAsUser(ctx, s.loggedInUser, shellCmd)
if err != nil {
if ctx.Err() == context.DeadlineExceeded {
return stdout, "", 124, fmt.Errorf("command timed out after %s", timeout)
}
return stdout, "", 1, err
}
return stdout, "", 0, nil
}
return runShellCmd(ctx, s.exec, timeout, shellCmd)
}

// checkPath checks if a binary is available, using the logged-in user's PATH when running as root.
func (s *NodeScanner) checkPath(ctx context.Context, name string) error {
if s.shouldRunAsUser() {
path, err := s.exec.RunAsUser(ctx, s.loggedInUser, "which "+name)
if err != nil || path == "" {
return fmt.Errorf("%s not found in user PATH", name)
}
return nil
}
_, err := s.exec.LookPath(name)
return err
}

// ScanGlobalPackages runs npm/yarn/pnpm list -g and returns raw base64-encoded results.
Expand All @@ -61,7 +122,7 @@ func (s *NodeScanner) ScanGlobalPackages(ctx context.Context) []model.NodeScanRe
}

func (s *NodeScanner) scanNPMGlobal(ctx context.Context) (model.NodeScanResult, bool) {
if _, err := s.exec.LookPath("npm"); err != nil {
if err := s.checkPath(ctx, "npm"); err != nil {
return model.NodeScanResult{}, false
}

Expand All @@ -72,7 +133,7 @@ func (s *NodeScanner) scanNPMGlobal(ctx context.Context) (model.NodeScanResult,
}

start := time.Now()
stdout, stderr, exitCode, _ := s.exec.RunWithTimeout(ctx, 60*time.Second, "npm", "list", "-g", "--json", "--depth=3")
stdout, stderr, exitCode, _ := s.runCmd(ctx, 60*time.Second, "npm", "list", "-g", "--json", "--depth=3")
duration := time.Since(start).Milliseconds()

errMsg := ""
Expand All @@ -94,7 +155,7 @@ func (s *NodeScanner) scanNPMGlobal(ctx context.Context) (model.NodeScanResult,
}

func (s *NodeScanner) scanYarnGlobal(ctx context.Context) (model.NodeScanResult, bool) {
if _, err := s.exec.LookPath("yarn"); err != nil {
if err := s.checkPath(ctx, "yarn"); err != nil {
return model.NodeScanResult{}, false
}

Expand All @@ -106,7 +167,7 @@ func (s *NodeScanner) scanYarnGlobal(ctx context.Context) (model.NodeScanResult,

start := time.Now()
shellCmd := "cd " + platformShellQuote(s.exec, globalDir) + " && yarn list --json --depth=0"
stdout, stderr, exitCode, _ := runShellCmd(ctx, s.exec, 60*time.Second, shellCmd)
stdout, stderr, exitCode, _ := s.runShellCmd(ctx, 60*time.Second, shellCmd)
duration := time.Since(start).Milliseconds()

errMsg := ""
Expand All @@ -128,7 +189,7 @@ func (s *NodeScanner) scanYarnGlobal(ctx context.Context) (model.NodeScanResult,
}

func (s *NodeScanner) scanPnpmGlobal(ctx context.Context) (model.NodeScanResult, bool) {
if _, err := s.exec.LookPath("pnpm"); err != nil {
if err := s.checkPath(ctx, "pnpm"); err != nil {
return model.NodeScanResult{}, false
}

Expand All @@ -140,7 +201,7 @@ func (s *NodeScanner) scanPnpmGlobal(ctx context.Context) (model.NodeScanResult,
globalDir = filepath.Dir(globalDir)

start := time.Now()
stdout, stderr, exitCode, _ := s.exec.RunWithTimeout(ctx, 60*time.Second, "pnpm", "list", "-g", "--json", "--depth=3")
stdout, stderr, exitCode, _ := s.runCmd(ctx, 60*time.Second, "pnpm", "list", "-g", "--json", "--depth=3")
duration := time.Since(start).Milliseconds()

errMsg := ""
Expand Down Expand Up @@ -286,7 +347,7 @@ func (s *NodeScanner) scanProject(ctx context.Context, projectDir string) model.
for _, a := range args {
cmdStr += " " + a
}
stdout, stderr, exitCode, _ := runShellCmd(ctx, s.exec, 30*time.Second, cmdStr)
stdout, stderr, exitCode, _ := s.runShellCmd(ctx, 30*time.Second, cmdStr)
duration := time.Since(start).Milliseconds()

errMsg := ""
Expand All @@ -308,15 +369,15 @@ func (s *NodeScanner) scanProject(ctx context.Context, projectDir string) model.
}

func (s *NodeScanner) getVersion(ctx context.Context, binary, flag string) string {
stdout, _, _, err := s.exec.RunWithTimeout(ctx, 10*time.Second, binary, flag)
stdout, _, _, err := s.runCmd(ctx, 10*time.Second, binary, flag)
if err != nil {
return "unknown"
}
return strings.TrimSpace(stdout)
}

func (s *NodeScanner) getOutput(ctx context.Context, binary string, args ...string) string {
stdout, _, _, err := s.exec.RunWithTimeout(ctx, 10*time.Second, binary, args...)
stdout, _, _, err := s.runCmd(ctx, 10*time.Second, binary, args...)
if err != nil {
return ""
}
Expand Down
2 changes: 1 addition & 1 deletion internal/detector/nodescan_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import (

func newTestScanner(exec *executor.Mock) *NodeScanner {
log := progress.NewLogger(false)
return NewNodeScanner(exec, log)
return NewNodeScanner(exec, log, "")
}

func TestNodeScanner_ScanNPMGlobal(t *testing.T) {
Expand Down
4 changes: 2 additions & 2 deletions internal/device/device.go
Original file line number Diff line number Diff line change
Expand Up @@ -124,8 +124,8 @@ func getDeveloperIdentity(exec executor.Executor) string {
return v
}
}
// Fallback to current username
u, err := exec.CurrentUser()
// Fallback to logged-in username (detects console user when running as root)
u, err := exec.LoggedInUser()
if err == nil {
return u.Username
}
Expand Down
33 changes: 33 additions & 0 deletions internal/executor/executor.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"os/user"
"path/filepath"
"runtime"
"strings"
"time"
)

Expand Down Expand Up @@ -45,6 +46,11 @@ type Executor interface {
HomeDir(username string) (string, error)
// Glob returns filenames matching a pattern.
Glob(pattern string) ([]string, error)
// LoggedInUser returns the actual logged-in console user.
// When running as root on macOS (e.g., via LaunchDaemon), this detects the
// real console user via /dev/console rather than returning root.
// Falls back to CurrentUser() when not root or on non-macOS platforms.
LoggedInUser() (*user.User, error)
// GOOS returns the runtime operating system.
GOOS() string
}
Expand Down Expand Up @@ -131,6 +137,33 @@ func (r *Real) Glob(pattern string) ([]string, error) {
return filepath.Glob(pattern)
}

func (r *Real) LoggedInUser() (*user.User, error) {
if runtime.GOOS != "darwin" || !r.IsRoot() {
return r.CurrentUser()
}

// On macOS running as root, detect the console user.
// This mirrors the bash script's get_logged_in_user_info() which uses
// stat -f%Su /dev/console to find who is actually logged in.
ctx := context.Background()
stdout, _, _, err := r.Run(ctx, "stat", "-f%Su", "/dev/console")
if err != nil {
return r.CurrentUser()
}
Comment thread
shubham-stepsecurity marked this conversation as resolved.

username := strings.TrimSpace(stdout)
if username == "" || username == "root" || username == "_windowserver" {
return r.CurrentUser()
}

u, err := user.Lookup(username)
if err != nil {
return r.CurrentUser()
}

return u, nil
}

func (r *Real) GOOS() string {
return runtime.GOOS
}
10 changes: 7 additions & 3 deletions internal/executor/mock.go
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,10 @@ func (m *Mock) Glob(pattern string) ([]string, error) {
return nil, nil
}

func (m *Mock) LoggedInUser() (*user.User, error) {
return m.CurrentUser()
}

func (m *Mock) GOOS() string {
m.mu.RLock()
defer m.mu.RUnlock()
Expand All @@ -286,9 +290,9 @@ type mockFileInfo struct {
dir bool
}

func (fi *mockFileInfo) Name() string { return fi.name }
func (fi *mockFileInfo) Size() int64 { return fi.size }
func (fi *mockFileInfo) IsDir() bool { return fi.dir }
func (fi *mockFileInfo) Name() string { return fi.name }
func (fi *mockFileInfo) Size() int64 { return fi.size }
func (fi *mockFileInfo) IsDir() bool { return fi.dir }
func (fi *mockFileInfo) ModTime() time.Time { return time.Time{} }
func (fi *mockFileInfo) Mode() os.FileMode { return 0o644 }
func (fi *mockFileInfo) Sys() any { return nil }
20 changes: 19 additions & 1 deletion internal/launchd/launchd.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,12 +68,24 @@ func Install(exec executor.Executor, log *progress.Logger) error {
}
}

// Resolve the real user's home directory for the plist.
// When running as root (LaunchDaemon), launchd provides a minimal environment
// without HOME, so os.UserHomeDir() would fail at runtime. We detect the
// logged-in console user now and bake their HOME into the plist.
userHome := ""
if exec.IsRoot() {
if u, err := exec.LoggedInUser(); err == nil {
userHome = u.HomeDir
}
}

// Generate plist
plistData := plistTemplateData{
Label: label,
BinaryPath: binaryPath,
IntervalSeconds: intervalSeconds,
LogDir: logDir,
UserHome: userHome,
}

f, err := os.Create(plistPath)
Expand Down Expand Up @@ -163,6 +175,7 @@ type plistTemplateData struct {
BinaryPath string
IntervalSeconds int
LogDir string
UserHome string // non-empty when running as root; baked into plist as HOME env var
}

const plistTmpl = `<?xml version="1.0" encoding="UTF-8"?>
Expand All @@ -179,7 +192,12 @@ const plistTmpl = `<?xml version="1.0" encoding="UTF-8"?>
<key>StartInterval</key>
<integer>{{.IntervalSeconds}}</integer>
<key>RunAtLoad</key>
<false/>
<false/>{{if .UserHome}}
<key>EnvironmentVariables</key>
<dict>
<key>HOME</key>
<string>{{.UserHome}}</string>
</dict>{{end}}
<key>StandardOutPath</key>
<string>{{.LogDir}}/agent.log</string>
<key>StandardErrorPath</key>
Expand Down
Loading
Loading