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 @@ Go CI ShellCheck CI License: Apache 2.0 - Version 1.9.1 + Version 1.9.2

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 = ` StartInterval {{.IntervalSeconds}} RunAtLoad - + {{if .UserHome}} + EnvironmentVariables + + HOME + {{.UserHome}} + {{end}} StandardOutPath {{.LogDir}}/agent.log StandardErrorPath 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 9a04793..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 @@ -35,13 +36,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. @@ -80,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 } @@ -89,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/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..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,6 +103,14 @@ 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. + // Skip "root" — if LoggedInUser() fell back to CurrentUser(), delegating + // via sudo -H -u root is pointless and changes PATH/env behavior. + loggedInUsername := "" + if u, err := exec.LoggedInUser(); err == nil && u.Username != "root" { + loggedInUsername = u.Username + } + // Resolve search dirs searchDirs := resolveSearchDirs(exec, cfg.SearchDirs) fmt.Fprintln(os.Stderr) @@ -201,7 +209,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 +383,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 }