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
77 changes: 77 additions & 0 deletions cmd/stepsecurity-dev-machine-guard/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ import (
"fmt"
"io"
"os"
"path/filepath"
"runtime"
"strings"
"time"

aiagentscli "github.com/step-security/dev-machine-guard/internal/aiagents/cli"
Expand All @@ -20,7 +22,9 @@ import (
"github.com/step-security/dev-machine-guard/internal/executor"
"github.com/step-security/dev-machine-guard/internal/launchd"
"github.com/step-security/dev-machine-guard/internal/output"
"github.com/step-security/dev-machine-guard/internal/paths"
"github.com/step-security/dev-machine-guard/internal/progress"
"github.com/step-security/dev-machine-guard/internal/progress/filelog"
"github.com/step-security/dev-machine-guard/internal/scan"
"github.com/step-security/dev-machine-guard/internal/schtasks"
"github.com/step-security/dev-machine-guard/internal/systemd"
Expand Down Expand Up @@ -84,6 +88,40 @@ func main() {

exec := executor.NewReal()

// Install dir resolution (see internal/paths.Home for the canonical
// chain): --install-dir CLI flag > $STEPSECURITY_HOME env var >
// install_dir config field > ~/.stepsecurity default. The CLI flag
// wins because it is the most explicit per-invocation override the
// operator can supply. We feed it to paths via SetOverride; an
// explicit `--install-dir=` (empty) is preserved and short-circuits
// the path computation below to disable file logging for this run.
//
// The capture is installed before the logger so every subsequent
// stderr write — including the pipe-tee in
// internal/telemetry/logcapture.go, which nests inside this one —
// flows through to disk.
if cfg.InstallDirSet {
paths.SetOverride(cfg.InstallDir) // may be "" = disabled
}
installDir := paths.Home()
disabled := cfg.InstallDirSet && cfg.InstallDir == ""
logFilePath := ""
if !disabled && installDir != "" {
logFilePath = filepath.Join(installDir, filelog.Filename)
// Pre-rotate BOTH files unconditionally. In interactive mode the
// stderr rotation is redundant with filelog.Start's own rotation
// pass (Start re-checks and no-ops on a missing path); in service
// mode StartIfEligible early-returns and Start never runs, so this
// explicit call is the only thing keeping agent.error.log bounded
// when the OS-level scheduler redirect is writing it. agent.log
// has the same property — the agent never writes it directly, so
// the only opportunity to cap it is at startup.
filelog.RotateIfOverCap(logFilePath, filelog.DefaultMaxBytes)
filelog.RotateIfOverCap(filepath.Join(installDir, filelog.StdoutFilename), filelog.DefaultMaxBytes)
}
capture, captureErr := filelog.StartIfEligible(logFilePath, filelog.DefaultMaxBytes)
defer func() { _ = capture.Stop() }()

// Log level resolution: default info → config file → CLI flag → --verbose → JSON override.
level := progress.LevelInfo
if config.LogLevel != "" {
Expand All @@ -104,6 +142,23 @@ func main() {
level = progress.LevelError
}
log := progress.NewLogger(level)
if captureErr != nil {
// Non-fatal: a read-only $HOME shouldn't block the run.
log.Warn("file logging disabled: %v", captureErr)
}

// Migration heads-up: if the operator has moved the install dir but
// the legacy ~/.stepsecurity still has agent state, surface that so
// they can decide whether to copy over old diagnostic files. Don't
// auto-move — too risky for v1 (silent overwrites, races with other
// processes, perms changes). Just point at the leftovers.
legacy := paths.LegacyHome()
if !disabled && legacy != "" && installDir != "" && installDir != legacy {
if leftovers := findLegacyLeftovers(legacy); len(leftovers) > 0 {
log.Warn("install dir is %s but the legacy default %s still has files: %s — copy them over manually if you want their history.",
installDir, legacy, strings.Join(leftovers, ", "))
}
}
log.Debug("resolved log level: %s (config=%q cli=%q verbose=%v output=%s)",
level, config.LogLevel, cfg.LogLevel, cfg.Verbose, cfg.OutputFormat)
log.Debug("config loaded: enterprise=%v api_endpoint=%q scan_freq=%q search_dirs=%v log_level=%q",
Expand Down Expand Up @@ -350,6 +405,28 @@ func scanJSONEncoder(w io.Writer) *json.Encoder {
return enc
}

// findLegacyLeftovers checks the legacy ~/.stepsecurity dir for agent
// files the operator may have moved (intentionally) to a new install
// dir. Returns basenames of present diagnostic files (config.json is
// excluded — it must stay at the legacy path as the bootstrap, so its
// presence there is expected and not a leftover to migrate).
func findLegacyLeftovers(legacy string) []string {
candidates := []string{
"agent.error.log",
"agent.error.log.prev",
"agent.log",
"agent.log.prev",
"ai-agent-hook-errors.jsonl",
}
var found []string
for _, name := range candidates {
if _, err := os.Stat(filepath.Join(legacy, name)); err == nil {
found = append(found, name)
}
}
return found
}

// runHookStateReconcile polls agent-api for the desired AI-agent hook
// state and reconciles local hook installation to match. Silent no-op
// in community mode (enterprise config missing) — the existing scan
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"ts":"2026-05-21T16:38:14.309922Z","stage":"install","code":"no_home","message":"should be silently dropped"}
7 changes: 4 additions & 3 deletions internal/aiagents/cli/errlog.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"time"

"github.com/step-security/dev-machine-guard/internal/aiagents/redact"
"github.com/step-security/dev-machine-guard/internal/paths"
)

// ErrorLogFilename is the basename of the per-user errors log. It lives
Expand Down Expand Up @@ -99,9 +100,9 @@ func errorLogPath() string {
if errorLogPathOverride != "" {
return errorLogPathOverride
}
home, err := os.UserHomeDir()
if err != nil || home == "" {
dir := paths.Home()
if dir == "" {
return ""
}
return filepath.Join(home, ".stepsecurity", ErrorLogFilename)
return filepath.Join(dir, ErrorLogFilename)
}
35 changes: 34 additions & 1 deletion internal/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ type Config struct {
ColorMode string // "auto", "always", "never"
Verbose bool // --verbose (shortcut for --log-level=debug)
LogLevel string // "" = unset; one of "error", "warn", "info", "debug"
InstallDir string // --install-dir=DIR base install directory; all non-bootstrap files (logs, hook errors, binary placement) live under this dir. "" w/ InstallDirSet=true means "explicitly disabled" (no file logging).
InstallDirSet bool // true if --install-dir was passed (empty value = disable file logging for this run)
EnableNPMScan *bool // nil=auto, true/false=explicit
EnableBrewScan *bool // nil=auto, true/false=explicit
EnablePythonScan *bool // nil=auto, true/false=explicit
Expand Down Expand Up @@ -220,6 +222,16 @@ func Parse(args []string) (*Config, error) {
default:
return nil, fmt.Errorf("invalid log level: %s (must be error, warn, info, or debug)", level)
}
case strings.HasPrefix(arg, "--install-dir="):
cfg.InstallDir = strings.TrimPrefix(arg, "--install-dir=")
cfg.InstallDirSet = true
case arg == "--install-dir":
i++
if i >= len(args) {
return nil, fmt.Errorf("--install-dir requires a directory argument (use --install-dir= to disable file logging)")
}
cfg.InstallDir = args[i]
cfg.InstallDirSet = true
case arg == "-v" || arg == "--version" || arg == "version":
_, _ = fmt.Fprintf(os.Stdout, "StepSecurity Dev Machine Guard v%s\n", buildinfo.VersionString())
os.Exit(0)
Expand Down Expand Up @@ -290,11 +302,21 @@ func parseHooks(args []string) (*Config, error) {
return nil, fmt.Errorf("unsupported agent: %s (supported: %s)", name, strings.Join(supportedHookAgents, ", "))
}
cfg.HooksAgent = name
case strings.HasPrefix(arg, "--install-dir="):
cfg.InstallDir = strings.TrimPrefix(arg, "--install-dir=")
cfg.InstallDirSet = true
case arg == "--install-dir":
i++
if i >= len(rest) {
return nil, fmt.Errorf("--install-dir requires a directory argument (use --install-dir= to disable file logging)")
}
cfg.InstallDir = rest[i]
cfg.InstallDirSet = true
case arg == "-h" || arg == "--help":
printHooksHelp()
os.Exit(0)
default:
return nil, fmt.Errorf("unknown option for `hooks %s`: %s (only --agent is accepted)", verb, arg)
return nil, fmt.Errorf("unknown option for `hooks %s`: %s (only --agent and --install-dir are accepted)", verb, arg)
}
}

Expand All @@ -316,6 +338,9 @@ Subcommands:
Options:
--agent <name> Target a specific agent (default: every detected agent).
Supported: %s
--install-dir=DIR Base directory the agent puts its files under
(default: ~/.stepsecurity). Pass --install-dir= (empty)
to disable file logging. Equivalent to $STEPSECURITY_HOME.

Examples:
%s hooks install # install for every detected agent
Expand Down Expand Up @@ -361,6 +386,14 @@ Options:
--npmrc Run ONLY the npm config audit (verbose pretty view; --json supported)
--pipconfig Run ONLY the pip config audit (verbose pretty view; --json supported)
--log-level=LEVEL Log level: error | warn | info | debug (default: info)
--install-dir=DIR Base directory the agent puts ALL its files under
(logs, hook errors, binary placement via loader).
Default: ~/.stepsecurity. The diagnostic log file is
<DIR>/agent.error.log, rotated at 5 MiB to .prev.
Equivalent to STEPSECURITY_HOME env var. Pass
--install-dir= (empty) to disable file logging for
this run. Note: config.json itself always lives at
~/.stepsecurity/config.json for bootstrap.
--verbose Shortcut for --log-level=debug
--color=WHEN Color mode: auto | always | never (default: auto)
-v, --version Show version
Expand Down
78 changes: 77 additions & 1 deletion internal/cli/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,9 @@ func TestParse_HooksAgentMissingValue(t *testing.T) {
}
}

// DMG global flags must not leak into the hooks group.
// DMG global flags must not leak into the hooks group. --install-dir
// is the deliberate exception — when hooks fail, the customer needs the
// same on-disk diagnostic file every other command produces.
func TestParse_HooksRejectsGlobalFlags(t *testing.T) {
cases := [][]string{
{"hooks", "install", "--json"},
Expand All @@ -287,6 +289,80 @@ func TestParse_HooksRejectsGlobalFlags(t *testing.T) {
}
}

func TestParse_InstallDir_EqualsForm(t *testing.T) {
cfg, err := Parse([]string{"--install-dir=/opt/sec"})
if err != nil {
t.Fatal(err)
}
if cfg.InstallDir != "/opt/sec" {
t.Errorf("InstallDir = %q, want /opt/sec", cfg.InstallDir)
}
if !cfg.InstallDirSet {
t.Error("InstallDirSet should be true after --install-dir=")
}
}

func TestParse_InstallDir_SpaceForm(t *testing.T) {
cfg, err := Parse([]string{"--install-dir", "/opt/sec"})
if err != nil {
t.Fatal(err)
}
if cfg.InstallDir != "/opt/sec" {
t.Errorf("InstallDir = %q, want /opt/sec", cfg.InstallDir)
}
if !cfg.InstallDirSet {
t.Error("InstallDirSet should be true after --install-dir <path>")
}
}

func TestParse_InstallDir_EmptyValueDisables(t *testing.T) {
cfg, err := Parse([]string{"--install-dir="})
if err != nil {
t.Fatal(err)
}
if cfg.InstallDir != "" {
t.Errorf("InstallDir = %q, want empty (disabled)", cfg.InstallDir)
}
if !cfg.InstallDirSet {
t.Error("InstallDirSet should be true (explicit empty is opt-out)")
}
}

func TestParse_InstallDir_SpaceFormMissingValue(t *testing.T) {
_, err := Parse([]string{"--install-dir"})
if err == nil {
t.Error("expected error for --install-dir without value (use --install-dir= to disable)")
}
}

func TestParse_InstallDir_AbsentLeavesUnset(t *testing.T) {
cfg, err := Parse([]string{})
if err != nil {
t.Fatal(err)
}
if cfg.InstallDir != "" || cfg.InstallDirSet {
t.Errorf("absent --install-dir should yield InstallDir=%q InstallDirSet=%v", cfg.InstallDir, cfg.InstallDirSet)
}
}

func TestParseHooks_AcceptsInstallDir(t *testing.T) {
cfg, err := Parse([]string{"hooks", "install", "--install-dir=/opt/sec"})
if err != nil {
t.Fatalf("hooks install --install-dir rejected: %v", err)
}
if cfg.InstallDir != "/opt/sec" {
t.Errorf("InstallDir = %q, want /opt/sec", cfg.InstallDir)
}

cfg, err = Parse([]string{"hooks", "uninstall", "--install-dir", "/opt/u"})
if err != nil {
t.Fatalf("hooks uninstall --install-dir rejected: %v", err)
}
if cfg.InstallDir != "/opt/u" {
t.Errorf("InstallDir = %q, want /opt/u", cfg.InstallDir)
}
}

// The `_hook` runtime is intentionally not handled by Parse — main.go
// intercepts it before any init runs to honor the fail-open contract.
// See internal/aiagents/cli/hook_test.go for handler-level tests and
Expand Down
42 changes: 42 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ var (
OutputFormat string // "" means default (pretty)
HTMLOutputFile string // "" means not set
LogLevel string // "" means default (info); one of error/warn/info/debug
InstallDir string // "" means default (~/.stepsecurity); non-empty makes the agent put all its files (logs, hook errors, future state) under this directory. Bootstrap config.json itself stays at the legacy location. Per-run opt-out is the CLI flag --install-dir=. Resolution: --install-dir flag > STEPSECURITY_HOME env > this field > default — see internal/paths.
)

// ConfigFile is the JSON structure persisted to ~/.stepsecurity/config.json.
Expand All @@ -39,6 +40,7 @@ type ConfigFile struct {
OutputFormat string `json:"output_format,omitempty"`
HTMLOutputFile string `json:"html_output_file,omitempty"`
LogLevel string `json:"log_level,omitempty"`
InstallDir string `json:"install_dir,omitempty"`
}

// userConfigDir returns ~/.stepsecurity — the per-user config location.
Expand Down Expand Up @@ -93,6 +95,26 @@ func WriteConfigFilePath() string {
return filepath.Join(writeConfigDir(), "config.json")
}

// LegacyDirName is the basename of the per-user agent directory under
// $HOME. config.json always lives here so the agent can bootstrap;
// other files (logs, hook errors, the binary) may be relocated via the
// resolved install dir — see internal/paths.
const LegacyDirName = ".stepsecurity"

// LegacyDir returns the per-user agent directory (~/.stepsecurity), used
// as the reference point for the install-dir migration warning in main:
// if the operator has moved the install dir but this directory still
// holds diagnostic files, the agent surfaces a heads-up. Returns "" when
// $HOME can't be resolved.
//
// Distinct from ConfigFilePath / WriteConfigFilePath above: those follow
// the machine-vs-user resolution that lets MSI-deployed installs share
// config with a scheduled task running as a logged-in user. LegacyDir is
// always per-user, regardless of elevation.
func LegacyDir() string {
return userConfigDir()
}

// Load reads the config file and applies values to the package-level variables.
// Values already set (not placeholders) are not overridden — build-time values take precedence.
func Load() {
Expand Down Expand Up @@ -142,6 +164,9 @@ func Load() {
if cfg.LogLevel != "" && LogLevel == "" {
LogLevel = cfg.LogLevel
}
if cfg.InstallDir != "" && InstallDir == "" {
InstallDir = cfg.InstallDir
}
}

// IsEnterpriseMode returns true if valid enterprise credentials are configured.
Expand Down Expand Up @@ -296,6 +321,15 @@ func RunConfigure() error {
existing.LogLevel = "info"
}

// Install directory override (empty = ~/.stepsecurity). All
// non-bootstrap files live under this directory: agent.log,
// agent.error.log (+ .prev rotation), ai-agent-hook-errors.jsonl,
// and the binary itself when placed via the loader script.
// Bootstrap config.json keeps living at the legacy ~/.stepsecurity
// path so the agent can always find it. To temporarily override
// for one run, pass --install-dir=PATH or set $STEPSECURITY_HOME.
existing.InstallDir = promptValue(reader, "Install Directory (blank = default)", existing.InstallDir)

// Save
if err := save(existing); err != nil {
return fmt.Errorf("saving configuration: %w", err)
Expand Down Expand Up @@ -422,6 +456,7 @@ func ShowConfigure() {
fmt.Printf(" %-24s %s\n", "HTML Output File:", displayValue(cfg.HTMLOutputFile))
}
fmt.Printf(" %-24s %s\n", "Log Level:", displayLogLevel(cfg.LogLevel))
fmt.Printf(" %-24s %s\n", "Install Directory:", displayInstallDir(cfg.InstallDir))
}

func displayValue(v string) string {
Expand Down Expand Up @@ -494,6 +529,13 @@ func displayLogLevel(level string) string {
}
}

func displayInstallDir(v string) string {
if v == "" {
return "~/.stepsecurity (default)"
}
return v
}

func isPlaceholder(v string) bool {
return strings.Contains(v, "{{")
}
Expand Down
Loading
Loading