From 716afae7a962ef82e1986ed6193755366e4682ed Mon Sep 17 00:00:00 2001
From: Shubham Malik
Date: Wed, 15 Apr 2026 09:46:59 +0530
Subject: [PATCH 1/3] fix(mdm): detect logged-in console user when running as
root
---
internal/detector/aicli.go | 2 +-
internal/detector/nodescan.go | 81 ++++++++++++++++++++++++++++-----
internal/device/device.go | 4 +-
internal/executor/executor.go | 32 +++++++++++++
internal/executor/mock.go | 4 ++
internal/launchd/launchd.go | 20 +++++++-
internal/progress/progress.go | 6 ++-
internal/scan/scanner.go | 2 +-
internal/telemetry/telemetry.go | 10 +++-
9 files changed, 141 insertions(+), 20 deletions(-)
diff --git a/internal/detector/aicli.go b/internal/detector/aicli.go
index bc55321..9adb942 100644
--- a/internal/detector/aicli.go
+++ b/internal/detector/aicli.go
@@ -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/nodescan.go b/internal/detector/nodescan.go
index 6c42fbc..fbaf1fa 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,70 @@ 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.
+func (s *NodeScanner) shouldRunAsUser() bool {
+ return 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.
+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 s.exec.RunWithTimeout(ctx, timeout, "bash", "-c", 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 +120,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 +131,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 +153,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
}
@@ -128,7 +187,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 +199,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 := ""
@@ -308,7 +367,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 +375,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/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..b2b021d 100644
--- a/internal/executor/executor.go
+++ b/internal/executor/executor.go
@@ -45,6 +45,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 +136,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..b850204 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()
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 = `
StartInterval
{{.IntervalSeconds}}
RunAtLoad
-
+ {{if .UserHome}}
+ EnvironmentVariables
+
+ HOME
+ {{.UserHome}}
+ {{end}}
StandardOutPath
{{.LogDir}}/agent.log
StandardErrorPath
diff --git a/internal/progress/progress.go b/internal/progress/progress.go
index 9a04793..822b462 100644
--- a/internal/progress/progress.go
+++ b/internal/progress/progress.go
@@ -35,13 +35,15 @@ func (l *Logger) Progress(format string, args ...any) {
if l.quiet {
return
}
- fmt.Fprintf(os.Stderr, "\033[2m[scanning]\033[0m %s\n", fmt.Sprintf(format, args...))
+ ts := time.Now().Format("2006-01-02 15:04:05")
+ fmt.Fprintf(os.Stderr, "\033[2m%s [scanning]\033[0m %s\n", ts, fmt.Sprintf(format, args...))
}
// Error always prints to stderr regardless of quiet mode.
// Format: [error] message
func (l *Logger) Error(format string, args ...any) {
- fmt.Fprintf(os.Stderr, "\033[0;31m[error]\033[0m %s\n", fmt.Sprintf(format, args...))
+ ts := time.Now().Format("2006-01-02 15:04:05")
+ fmt.Fprintf(os.Stderr, "%s \033[0;31m[error]\033[0m %s\n", ts, fmt.Sprintf(format, args...))
}
// StepStart begins a labeled progress step with a spinner.
diff --git a/internal/scan/scanner.go b/internal/scan/scanner.go
index a7183c8..db7e933 100644
--- a/internal/scan/scanner.go
+++ b/internal/scan/scanner.go
@@ -140,7 +140,7 @@ func resolveSearchDirs(exec executor.Executor, dirs []string) []string {
resolved := make([]string, 0, len(dirs))
for _, d := range dirs {
if d == "$HOME" {
- u, err := exec.CurrentUser()
+ u, err := exec.LoggedInUser()
if err == nil {
d = u.HomeDir
}
diff --git a/internal/telemetry/telemetry.go b/internal/telemetry/telemetry.go
index d33fbb0..4e25ee3 100644
--- a/internal/telemetry/telemetry.go
+++ b/internal/telemetry/telemetry.go
@@ -103,6 +103,12 @@ func Run(exec executor.Executor, log *progress.Logger, cfg *cli.Config) error {
log.Progress("OS Version: %s", dev.OSVersion)
log.Progress("Developer: %s", dev.UserIdentity)
+ // Detect logged-in user for running commands as the real user when root
+ loggedInUsername := ""
+ if loggedInUser, err := exec.LoggedInUser(); err == nil {
+ loggedInUsername = loggedInUser.Username
+ }
+
// Resolve search dirs
searchDirs := resolveSearchDirs(exec, cfg.SearchDirs)
fmt.Fprintln(os.Stderr)
@@ -201,7 +207,7 @@ func Run(exec executor.Executor, log *progress.Logger, cfg *cli.Config) error {
fmt.Fprintln(os.Stderr)
log.Progress("Scanning globally installed packages...")
- nodeScanner := detector.NewNodeScanner(exec, log)
+ nodeScanner := detector.NewNodeScanner(exec, log, loggedInUsername)
globalPkgs = nodeScanner.ScanGlobalPackages(ctx)
log.Progress(" Found %d global package location(s)", len(globalPkgs))
fmt.Fprintln(os.Stderr)
@@ -375,7 +381,7 @@ func resolveSearchDirs(exec executor.Executor, dirs []string) []string {
resolved := make([]string, 0, len(dirs))
for _, d := range dirs {
if d == "$HOME" {
- u, err := exec.CurrentUser()
+ u, err := exec.LoggedInUser()
if err == nil {
d = u.HomeDir
}
From 9478f12627b9f4b344da343c42cc6264127d09c6 Mon Sep 17 00:00:00 2001
From: Shubham Malik
Date: Wed, 15 Apr 2026 11:56:21 +0530
Subject: [PATCH 2/3] feat(mdm): bump version to v1.9.2
---
CHANGELOG.md | 8 ++++++++
README.md | 2 +-
examples/sample-output.json | 2 +-
internal/buildinfo/version.go | 2 +-
4 files changed, 11 insertions(+), 3 deletions(-)
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"
)
From cf90d5074e2dc9aca0eafaff3d81f48d03e222c0 Mon Sep 17 00:00:00 2001
From: Shubham Malik
Date: Wed, 15 Apr 2026 12:42:31 +0530
Subject: [PATCH 3/3] chore(mdm): resolve conflict & address copilot comments
---
internal/detector/aicli.go | 8 ++---
internal/detector/ide.go | 4 +--
internal/detector/nodescan.go | 10 ++++---
internal/detector/nodescan_test.go | 2 +-
internal/executor/executor.go | 1 +
internal/executor/mock.go | 6 ++--
internal/lock/lock.go | 1 -
internal/model/model.go | 48 +++++++++++++++---------------
internal/output/pretty.go | 3 +-
internal/progress/progress.go | 21 ++++++-------
internal/telemetry/telemetry.go | 14 +++++----
11 files changed, 62 insertions(+), 56 deletions(-)
diff --git a/internal/detector/aicli.go b/internal/detector/aicli.go
index 9adb942..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",
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 fbaf1fa..3304be2 100644
--- a/internal/detector/nodescan.go
+++ b/internal/detector/nodescan.go
@@ -41,8 +41,9 @@ func NewNodeScanner(exec executor.Executor, log *progress.Logger, loggedInUser s
}
// 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.IsRoot() && s.loggedInUser != ""
+ return s.exec.GOOS() != "windows" && s.exec.IsRoot() && s.loggedInUser != ""
}
// runCmd runs a command, delegating to the logged-in user when running as root.
@@ -68,6 +69,7 @@ func (s *NodeScanner) runCmd(ctx context.Context, timeout time.Duration, name st
}
// 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)
@@ -81,7 +83,7 @@ func (s *NodeScanner) runShellCmd(ctx context.Context, timeout time.Duration, sh
}
return stdout, "", 0, nil
}
- return s.exec.RunWithTimeout(ctx, timeout, "bash", "-c", shellCmd)
+ 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.
@@ -165,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 := ""
@@ -345,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 := ""
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/executor/executor.go b/internal/executor/executor.go
index b2b021d..8b15cbb 100644
--- a/internal/executor/executor.go
+++ b/internal/executor/executor.go
@@ -9,6 +9,7 @@ import (
"os/user"
"path/filepath"
"runtime"
+ "strings"
"time"
)
diff --git a/internal/executor/mock.go b/internal/executor/mock.go
index b850204..cfb79a7 100644
--- a/internal/executor/mock.go
+++ b/internal/executor/mock.go
@@ -290,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/lock/lock.go b/internal/lock/lock.go
index f9d488f..828163c 100644
--- a/internal/lock/lock.go
+++ b/internal/lock/lock.go
@@ -57,4 +57,3 @@ func (l *Lock) Release() {
_ = os.Remove(l.path)
}
}
-
diff --git a/internal/model/model.go b/internal/model/model.go
index 066a1ab..d394e86 100644
--- a/internal/model/model.go
+++ b/internal/model/model.go
@@ -2,18 +2,18 @@ package model
// ScanResult is the community-mode JSON output structure.
type ScanResult struct {
- AgentVersion string `json:"agent_version"`
- AgentURL string `json:"agent_url"`
- ScanTimestamp int64 `json:"scan_timestamp"`
- ScanTimestampISO string `json:"scan_timestamp_iso"`
- Device Device `json:"device"`
- AIAgentsAndTools []AITool `json:"ai_agents_and_tools"`
- IDEInstallations []IDE `json:"ide_installations"`
- IDEExtensions []Extension `json:"ide_extensions"`
- MCPConfigs []MCPConfig `json:"mcp_configs"`
- NodePkgManagers []PkgManager `json:"node_package_managers"`
- NodePackages []any `json:"node_packages"`
- Summary Summary `json:"summary"`
+ AgentVersion string `json:"agent_version"`
+ AgentURL string `json:"agent_url"`
+ ScanTimestamp int64 `json:"scan_timestamp"`
+ ScanTimestampISO string `json:"scan_timestamp_iso"`
+ Device Device `json:"device"`
+ AIAgentsAndTools []AITool `json:"ai_agents_and_tools"`
+ IDEInstallations []IDE `json:"ide_installations"`
+ IDEExtensions []Extension `json:"ide_extensions"`
+ MCPConfigs []MCPConfig `json:"mcp_configs"`
+ NodePkgManagers []PkgManager `json:"node_package_managers"`
+ NodePackages []any `json:"node_packages"`
+ Summary Summary `json:"summary"`
}
type Device struct {
@@ -63,9 +63,9 @@ type MCPConfig struct {
// MCPConfigEnterprise includes base64-encoded content for enterprise mode.
type MCPConfigEnterprise struct {
- ConfigSource string `json:"config_source"`
- ConfigPath string `json:"config_path"`
- Vendor string `json:"vendor"`
+ ConfigSource string `json:"config_source"`
+ ConfigPath string `json:"config_path"`
+ Vendor string `json:"vendor"`
ConfigContentBase64 string `json:"config_content_base64,omitempty"`
}
@@ -86,13 +86,13 @@ type Summary struct {
// NodeScanResult holds raw scan output for enterprise telemetry.
// Used for both global packages and per-project scans.
type NodeScanResult struct {
- ProjectPath string `json:"project_path"`
- PackageManager string `json:"package_manager"`
- PMVersion string `json:"package_manager_version"`
- WorkingDirectory string `json:"working_directory"`
- RawStdoutBase64 string `json:"raw_stdout_base64"`
- RawStderrBase64 string `json:"raw_stderr_base64"`
- Error string `json:"error"`
- ExitCode int `json:"exit_code"`
- ScanDurationMs int64 `json:"scan_duration_ms"`
+ ProjectPath string `json:"project_path"`
+ PackageManager string `json:"package_manager"`
+ PMVersion string `json:"package_manager_version"`
+ WorkingDirectory string `json:"working_directory"`
+ RawStdoutBase64 string `json:"raw_stdout_base64"`
+ RawStderrBase64 string `json:"raw_stderr_base64"`
+ Error string `json:"error"`
+ ExitCode int `json:"exit_code"`
+ ScanDurationMs int64 `json:"scan_duration_ms"`
}
diff --git a/internal/output/pretty.go b/internal/output/pretty.go
index 19348be..6c45f09 100644
--- a/internal/output/pretty.go
+++ b/internal/output/pretty.go
@@ -11,8 +11,9 @@ import (
"github.com/step-security/dev-machine-guard/internal/model"
)
-//nolint:errcheck // fmt.Fprint* to io.Writer; errors surface through the writer
// Pretty writes human-readable formatted output.
+//
+//nolint:errcheck // fmt.Fprint* to io.Writer; errors surface through the writer
func Pretty(w io.Writer, result *model.ScanResult, colorMode string) error {
c := setupColors(colorMode)
diff --git a/internal/progress/progress.go b/internal/progress/progress.go
index 822b462..0450d4e 100644
--- a/internal/progress/progress.go
+++ b/internal/progress/progress.go
@@ -8,12 +8,13 @@ import (
)
// Logger handles progress output to stderr.
-// Logging format matches the shell script:
-// [scanning] message — progress (suppressed in quiet mode)
-// [error] message — errors (never suppressed)
-// ⠋ label... (Xms) — spinner animation
-// ✓ label (Xms) — step done
-// ○ label (skipped) — step skipped
+// Logging format:
+//
+// 2006-01-02 15:04:05 [scanning] message — progress (suppressed in quiet mode)
+// 2006-01-02 15:04:05 [error] message — errors (never suppressed)
+// ⠋ label... (Xms) — spinner animation
+// ✓ label (Xms) — step done
+// ○ label (skipped) — step skipped
type Logger struct {
quiet bool
spinner *spinner
@@ -82,8 +83,8 @@ type spinner struct {
}
type stopMsg struct {
- kind string // "done" or "skip"
- reason string
+ kind string // "done" or "skip"
+ reason string
elapsed time.Duration
}
@@ -91,9 +92,9 @@ var spinnerFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "
func newSpinner(label string) *spinner {
return &spinner{
- label: label,
+ label: label,
startedAt: time.Now(),
- stopCh: make(chan stopMsg, 1),
+ stopCh: make(chan stopMsg, 1),
}
}
diff --git a/internal/telemetry/telemetry.go b/internal/telemetry/telemetry.go
index 4e25ee3..17cb0e2 100644
--- a/internal/telemetry/telemetry.go
+++ b/internal/telemetry/telemetry.go
@@ -34,9 +34,9 @@ type Payload struct {
CollectedAt int64 `json:"collected_at"`
NoUserLoggedIn bool `json:"no_user_logged_in"`
- IDEExtensions []model.Extension `json:"ide_extensions"`
- IDEInstallations []model.IDE `json:"ide_installations"`
- NodePkgManagers []model.PkgManager `json:"node_package_managers"`
+ IDEExtensions []model.Extension `json:"ide_extensions"`
+ IDEInstallations []model.IDE `json:"ide_installations"`
+ NodePkgManagers []model.PkgManager `json:"node_package_managers"`
NodeGlobalPackages []model.NodeScanResult `json:"node_global_packages"`
NodeProjects []model.NodeScanResult `json:"node_projects"`
AIAgents []model.AITool `json:"ai_agents"`
@@ -103,10 +103,12 @@ func Run(exec executor.Executor, log *progress.Logger, cfg *cli.Config) error {
log.Progress("OS Version: %s", dev.OSVersion)
log.Progress("Developer: %s", dev.UserIdentity)
- // Detect logged-in user for running commands as the real user when root
+ // Detect logged-in user for running commands as the real user when root.
+ // Skip "root" — if LoggedInUser() fell back to CurrentUser(), delegating
+ // via sudo -H -u root is pointless and changes PATH/env behavior.
loggedInUsername := ""
- if loggedInUser, err := exec.LoggedInUser(); err == nil {
- loggedInUsername = loggedInUser.Username
+ if u, err := exec.LoggedInUser(); err == nil && u.Username != "root" {
+ loggedInUsername = u.Username
}
// Resolve search dirs