diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1c6316e..7f8578d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
@@ -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
diff --git a/README.md b/README.md
index dca9199..69fc527 100644
--- a/README.md
+++ b/README.md
@@ -12,7 +12,7 @@
-
+
diff --git a/examples/sample-output.json b/examples/sample-output.json
index ce950aa..75ebd37 100644
--- a/examples/sample-output.json
+++ b/examples/sample-output.json
@@ -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": {
diff --git a/internal/buildinfo/version.go b/internal/buildinfo/version.go
index 949b9b7..b169519 100644
--- a/internal/buildinfo/version.go
+++ b/internal/buildinfo/version.go
@@ -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"
)
diff --git a/internal/detector/aicli.go b/internal/detector/aicli.go
index bc55321..f0b1dff 100644
--- a/internal/detector/aicli.go
+++ b/internal/detector/aicli.go
@@ -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",
@@ -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()
}
diff --git a/internal/detector/ide.go b/internal/detector/ide.go
index d3e3197..231a41c 100644
--- a/internal/detector/ide.go
+++ b/internal/detector/ide.go
@@ -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`},
},
}
diff --git a/internal/detector/nodescan.go b/internal/detector/nodescan.go
index 6c42fbc..3304be2 100644
--- a/internal/detector/nodescan.go
+++ b/internal/detector/nodescan.go
@@ -3,6 +3,7 @@ package detector
import (
"context"
"encoding/base64"
+ "fmt"
"os"
"path/filepath"
"sort"
@@ -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
+ }
+ 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...)
+}
+
+// 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.
@@ -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
}
@@ -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 := ""
@@ -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
}
@@ -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 := ""
@@ -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
}
@@ -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 := ""
@@ -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 := ""
@@ -308,7 +369,7 @@ 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"
}
@@ -316,7 +377,7 @@ func (s *NodeScanner) getVersion(ctx context.Context, binary, flag string) strin
}
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 ""
}
diff --git a/internal/detector/nodescan_test.go b/internal/detector/nodescan_test.go
index 63ff8d2..0d047b8 100644
--- a/internal/detector/nodescan_test.go
+++ b/internal/detector/nodescan_test.go
@@ -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) {
diff --git a/internal/device/device.go b/internal/device/device.go
index 0ad396e..b054b8d 100644
--- a/internal/device/device.go
+++ b/internal/device/device.go
@@ -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
}
diff --git a/internal/executor/executor.go b/internal/executor/executor.go
index ff5f30d..8b15cbb 100644
--- a/internal/executor/executor.go
+++ b/internal/executor/executor.go
@@ -9,6 +9,7 @@ import (
"os/user"
"path/filepath"
"runtime"
+ "strings"
"time"
)
@@ -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
}
@@ -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()
+ }
+
+ 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
}
diff --git a/internal/executor/mock.go b/internal/executor/mock.go
index 153b9e4..cfb79a7 100644
--- a/internal/executor/mock.go
+++ b/internal/executor/mock.go
@@ -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()
@@ -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 }
diff --git a/internal/launchd/launchd.go b/internal/launchd/launchd.go
index 4539082..85368d9 100644
--- a/internal/launchd/launchd.go
+++ b/internal/launchd/launchd.go
@@ -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)
@@ -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 = `
@@ -179,7 +192,12 @@ const plistTmpl = `