Skip to content

Commit a839a7b

Browse files
feat(mdm): configurable install dir + persistent stderr logs
1 parent 6dec9e3 commit a839a7b

16 files changed

Lines changed: 1134 additions & 65 deletions

File tree

cmd/stepsecurity-dev-machine-guard/main.go

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@ import (
66
"fmt"
77
"io"
88
"os"
9+
"path/filepath"
910
"runtime"
11+
"strings"
1012
"time"
1113

1214
aiagentscli "github.com/step-security/dev-machine-guard/internal/aiagents/cli"
@@ -20,7 +22,9 @@ import (
2022
"github.com/step-security/dev-machine-guard/internal/executor"
2123
"github.com/step-security/dev-machine-guard/internal/launchd"
2224
"github.com/step-security/dev-machine-guard/internal/output"
25+
"github.com/step-security/dev-machine-guard/internal/paths"
2326
"github.com/step-security/dev-machine-guard/internal/progress"
27+
"github.com/step-security/dev-machine-guard/internal/progress/filelog"
2428
"github.com/step-security/dev-machine-guard/internal/scan"
2529
"github.com/step-security/dev-machine-guard/internal/schtasks"
2630
"github.com/step-security/dev-machine-guard/internal/systemd"
@@ -84,6 +88,40 @@ func main() {
8488

8589
exec := executor.NewReal()
8690

91+
// Install dir resolution (see internal/paths.Home for the canonical
92+
// chain): --install-dir CLI flag > $STEPSECURITY_HOME env var >
93+
// install_dir config field > ~/.stepsecurity default. The CLI flag
94+
// wins because it is the most explicit per-invocation override the
95+
// operator can supply. We feed it to paths via SetOverride; an
96+
// explicit `--install-dir=` (empty) is preserved and short-circuits
97+
// the path computation below to disable file logging for this run.
98+
//
99+
// The capture is installed before the logger so every subsequent
100+
// stderr write — including the pipe-tee in
101+
// internal/telemetry/logcapture.go, which nests inside this one —
102+
// flows through to disk.
103+
if cfg.InstallDirSet {
104+
paths.SetOverride(cfg.InstallDir) // may be "" = disabled
105+
}
106+
installDir := paths.Home()
107+
disabled := cfg.InstallDirSet && cfg.InstallDir == ""
108+
logFilePath := ""
109+
if !disabled && installDir != "" {
110+
logFilePath = filepath.Join(installDir, filelog.Filename)
111+
// Pre-rotate BOTH files unconditionally. In interactive mode the
112+
// stderr rotation is redundant with filelog.Start's own rotation
113+
// pass (Start re-checks and no-ops on a missing path); in service
114+
// mode StartIfEligible early-returns and Start never runs, so this
115+
// explicit call is the only thing keeping agent.error.log bounded
116+
// when the OS-level scheduler redirect is writing it. agent.log
117+
// has the same property — the agent never writes it directly, so
118+
// the only opportunity to cap it is at startup.
119+
filelog.RotateIfOverCap(logFilePath, filelog.DefaultMaxBytes)
120+
filelog.RotateIfOverCap(filepath.Join(installDir, filelog.StdoutFilename), filelog.DefaultMaxBytes)
121+
}
122+
capture, captureErr := filelog.StartIfEligible(logFilePath, filelog.DefaultMaxBytes)
123+
defer func() { _ = capture.Stop() }()
124+
87125
// Log level resolution: default info → config file → CLI flag → --verbose → JSON override.
88126
level := progress.LevelInfo
89127
if config.LogLevel != "" {
@@ -104,6 +142,23 @@ func main() {
104142
level = progress.LevelError
105143
}
106144
log := progress.NewLogger(level)
145+
if captureErr != nil {
146+
// Non-fatal: a read-only $HOME shouldn't block the run.
147+
log.Warn("file logging disabled: %v", captureErr)
148+
}
149+
150+
// Migration heads-up: if the operator has moved the install dir but
151+
// the legacy ~/.stepsecurity still has agent state, surface that so
152+
// they can decide whether to copy over old diagnostic files. Don't
153+
// auto-move — too risky for v1 (silent overwrites, races with other
154+
// processes, perms changes). Just point at the leftovers.
155+
legacy := paths.LegacyHome()
156+
if !disabled && legacy != "" && installDir != "" && installDir != legacy {
157+
if leftovers := findLegacyLeftovers(legacy); len(leftovers) > 0 {
158+
log.Warn("install dir is %s but the legacy default %s still has files: %s — copy them over manually if you want their history.",
159+
installDir, legacy, strings.Join(leftovers, ", "))
160+
}
161+
}
107162
log.Debug("resolved log level: %s (config=%q cli=%q verbose=%v output=%s)",
108163
level, config.LogLevel, cfg.LogLevel, cfg.Verbose, cfg.OutputFormat)
109164
log.Debug("config loaded: enterprise=%v api_endpoint=%q scan_freq=%q search_dirs=%v log_level=%q",
@@ -350,6 +405,28 @@ func scanJSONEncoder(w io.Writer) *json.Encoder {
350405
return enc
351406
}
352407

408+
// findLegacyLeftovers checks the legacy ~/.stepsecurity dir for agent
409+
// files the operator may have moved (intentionally) to a new install
410+
// dir. Returns basenames of present diagnostic files (config.json is
411+
// excluded — it must stay at the legacy path as the bootstrap, so its
412+
// presence there is expected and not a leftover to migrate).
413+
func findLegacyLeftovers(legacy string) []string {
414+
candidates := []string{
415+
"agent.error.log",
416+
"agent.error.log.prev",
417+
"agent.log",
418+
"agent.log.prev",
419+
"ai-agent-hook-errors.jsonl",
420+
}
421+
var found []string
422+
for _, name := range candidates {
423+
if _, err := os.Stat(filepath.Join(legacy, name)); err == nil {
424+
found = append(found, name)
425+
}
426+
}
427+
return found
428+
}
429+
353430
// runHookStateReconcile polls agent-api for the desired AI-agent hook
354431
// state and reconciles local hook installation to match. Silent no-op
355432
// in community mode (enterprise config missing) — the existing scan
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{"ts":"2026-05-21T16:38:14.309922Z","stage":"install","code":"no_home","message":"should be silently dropped"}

internal/aiagents/cli/errlog.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"time"
88

99
"github.com/step-security/dev-machine-guard/internal/aiagents/redact"
10+
"github.com/step-security/dev-machine-guard/internal/paths"
1011
)
1112

1213
// ErrorLogFilename is the basename of the per-user errors log. It lives
@@ -99,9 +100,9 @@ func errorLogPath() string {
99100
if errorLogPathOverride != "" {
100101
return errorLogPathOverride
101102
}
102-
home, err := os.UserHomeDir()
103-
if err != nil || home == "" {
103+
dir := paths.Home()
104+
if dir == "" {
104105
return ""
105106
}
106-
return filepath.Join(home, ".stepsecurity", ErrorLogFilename)
107+
return filepath.Join(dir, ErrorLogFilename)
107108
}

internal/cli/cli.go

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ type Config struct {
2424
ColorMode string // "auto", "always", "never"
2525
Verbose bool // --verbose (shortcut for --log-level=debug)
2626
LogLevel string // "" = unset; one of "error", "warn", "info", "debug"
27+
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).
28+
InstallDirSet bool // true if --install-dir was passed (empty value = disable file logging for this run)
2729
EnableNPMScan *bool // nil=auto, true/false=explicit
2830
EnableBrewScan *bool // nil=auto, true/false=explicit
2931
EnablePythonScan *bool // nil=auto, true/false=explicit
@@ -220,6 +222,16 @@ func Parse(args []string) (*Config, error) {
220222
default:
221223
return nil, fmt.Errorf("invalid log level: %s (must be error, warn, info, or debug)", level)
222224
}
225+
case strings.HasPrefix(arg, "--install-dir="):
226+
cfg.InstallDir = strings.TrimPrefix(arg, "--install-dir=")
227+
cfg.InstallDirSet = true
228+
case arg == "--install-dir":
229+
i++
230+
if i >= len(args) {
231+
return nil, fmt.Errorf("--install-dir requires a directory argument (use --install-dir= to disable file logging)")
232+
}
233+
cfg.InstallDir = args[i]
234+
cfg.InstallDirSet = true
223235
case arg == "-v" || arg == "--version" || arg == "version":
224236
_, _ = fmt.Fprintf(os.Stdout, "StepSecurity Dev Machine Guard v%s\n", buildinfo.VersionString())
225237
os.Exit(0)
@@ -290,11 +302,21 @@ func parseHooks(args []string) (*Config, error) {
290302
return nil, fmt.Errorf("unsupported agent: %s (supported: %s)", name, strings.Join(supportedHookAgents, ", "))
291303
}
292304
cfg.HooksAgent = name
305+
case strings.HasPrefix(arg, "--install-dir="):
306+
cfg.InstallDir = strings.TrimPrefix(arg, "--install-dir=")
307+
cfg.InstallDirSet = true
308+
case arg == "--install-dir":
309+
i++
310+
if i >= len(rest) {
311+
return nil, fmt.Errorf("--install-dir requires a directory argument (use --install-dir= to disable file logging)")
312+
}
313+
cfg.InstallDir = rest[i]
314+
cfg.InstallDirSet = true
293315
case arg == "-h" || arg == "--help":
294316
printHooksHelp()
295317
os.Exit(0)
296318
default:
297-
return nil, fmt.Errorf("unknown option for `hooks %s`: %s (only --agent is accepted)", verb, arg)
319+
return nil, fmt.Errorf("unknown option for `hooks %s`: %s (only --agent and --install-dir are accepted)", verb, arg)
298320
}
299321
}
300322

@@ -316,6 +338,9 @@ Subcommands:
316338
Options:
317339
--agent <name> Target a specific agent (default: every detected agent).
318340
Supported: %s
341+
--install-dir=DIR Base directory the agent puts its files under
342+
(default: ~/.stepsecurity). Pass --install-dir= (empty)
343+
to disable file logging. Equivalent to $STEPSECURITY_HOME.
319344
320345
Examples:
321346
%s hooks install # install for every detected agent
@@ -361,6 +386,14 @@ Options:
361386
--npmrc Run ONLY the npm config audit (verbose pretty view; --json supported)
362387
--pipconfig Run ONLY the pip config audit (verbose pretty view; --json supported)
363388
--log-level=LEVEL Log level: error | warn | info | debug (default: info)
389+
--install-dir=DIR Base directory the agent puts ALL its files under
390+
(logs, hook errors, binary placement via loader).
391+
Default: ~/.stepsecurity. The diagnostic log file is
392+
<DIR>/agent.error.log, rotated at 5 MiB to .prev.
393+
Equivalent to STEPSECURITY_HOME env var. Pass
394+
--install-dir= (empty) to disable file logging for
395+
this run. Note: config.json itself always lives at
396+
~/.stepsecurity/config.json for bootstrap.
364397
--verbose Shortcut for --log-level=debug
365398
--color=WHEN Color mode: auto | always | never (default: auto)
366399
-v, --version Show version

internal/cli/cli_test.go

Lines changed: 77 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -269,7 +269,9 @@ func TestParse_HooksAgentMissingValue(t *testing.T) {
269269
}
270270
}
271271

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

292+
func TestParse_InstallDir_EqualsForm(t *testing.T) {
293+
cfg, err := Parse([]string{"--install-dir=/opt/sec"})
294+
if err != nil {
295+
t.Fatal(err)
296+
}
297+
if cfg.InstallDir != "/opt/sec" {
298+
t.Errorf("InstallDir = %q, want /opt/sec", cfg.InstallDir)
299+
}
300+
if !cfg.InstallDirSet {
301+
t.Error("InstallDirSet should be true after --install-dir=")
302+
}
303+
}
304+
305+
func TestParse_InstallDir_SpaceForm(t *testing.T) {
306+
cfg, err := Parse([]string{"--install-dir", "/opt/sec"})
307+
if err != nil {
308+
t.Fatal(err)
309+
}
310+
if cfg.InstallDir != "/opt/sec" {
311+
t.Errorf("InstallDir = %q, want /opt/sec", cfg.InstallDir)
312+
}
313+
if !cfg.InstallDirSet {
314+
t.Error("InstallDirSet should be true after --install-dir <path>")
315+
}
316+
}
317+
318+
func TestParse_InstallDir_EmptyValueDisables(t *testing.T) {
319+
cfg, err := Parse([]string{"--install-dir="})
320+
if err != nil {
321+
t.Fatal(err)
322+
}
323+
if cfg.InstallDir != "" {
324+
t.Errorf("InstallDir = %q, want empty (disabled)", cfg.InstallDir)
325+
}
326+
if !cfg.InstallDirSet {
327+
t.Error("InstallDirSet should be true (explicit empty is opt-out)")
328+
}
329+
}
330+
331+
func TestParse_InstallDir_SpaceFormMissingValue(t *testing.T) {
332+
_, err := Parse([]string{"--install-dir"})
333+
if err == nil {
334+
t.Error("expected error for --install-dir without value (use --install-dir= to disable)")
335+
}
336+
}
337+
338+
func TestParse_InstallDir_AbsentLeavesUnset(t *testing.T) {
339+
cfg, err := Parse([]string{})
340+
if err != nil {
341+
t.Fatal(err)
342+
}
343+
if cfg.InstallDir != "" || cfg.InstallDirSet {
344+
t.Errorf("absent --install-dir should yield InstallDir=%q InstallDirSet=%v", cfg.InstallDir, cfg.InstallDirSet)
345+
}
346+
}
347+
348+
func TestParseHooks_AcceptsInstallDir(t *testing.T) {
349+
cfg, err := Parse([]string{"hooks", "install", "--install-dir=/opt/sec"})
350+
if err != nil {
351+
t.Fatalf("hooks install --install-dir rejected: %v", err)
352+
}
353+
if cfg.InstallDir != "/opt/sec" {
354+
t.Errorf("InstallDir = %q, want /opt/sec", cfg.InstallDir)
355+
}
356+
357+
cfg, err = Parse([]string{"hooks", "uninstall", "--install-dir", "/opt/u"})
358+
if err != nil {
359+
t.Fatalf("hooks uninstall --install-dir rejected: %v", err)
360+
}
361+
if cfg.InstallDir != "/opt/u" {
362+
t.Errorf("InstallDir = %q, want /opt/u", cfg.InstallDir)
363+
}
364+
}
365+
290366
// The `_hook` runtime is intentionally not handled by Parse — main.go
291367
// intercepts it before any init runs to honor the fail-open contract.
292368
// See internal/aiagents/cli/hook_test.go for handler-level tests and

internal/config/config.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ var (
2323
OutputFormat string // "" means default (pretty)
2424
HTMLOutputFile string // "" means not set
2525
LogLevel string // "" means default (info); one of error/warn/info/debug
26+
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.
2627
)
2728

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

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

98+
// LegacyDirName is the basename of the per-user agent directory under
99+
// $HOME. config.json always lives here so the agent can bootstrap;
100+
// other files (logs, hook errors, the binary) may be relocated via the
101+
// resolved install dir — see internal/paths.
102+
const LegacyDirName = ".stepsecurity"
103+
104+
// LegacyDir returns the per-user agent directory (~/.stepsecurity), used
105+
// as the reference point for the install-dir migration warning in main:
106+
// if the operator has moved the install dir but this directory still
107+
// holds diagnostic files, the agent surfaces a heads-up. Returns "" when
108+
// $HOME can't be resolved.
109+
//
110+
// Distinct from ConfigFilePath / WriteConfigFilePath above: those follow
111+
// the machine-vs-user resolution that lets MSI-deployed installs share
112+
// config with a scheduled task running as a logged-in user. LegacyDir is
113+
// always per-user, regardless of elevation.
114+
func LegacyDir() string {
115+
return userConfigDir()
116+
}
117+
96118
// Load reads the config file and applies values to the package-level variables.
97119
// Values already set (not placeholders) are not overridden — build-time values take precedence.
98120
func Load() {
@@ -142,6 +164,9 @@ func Load() {
142164
if cfg.LogLevel != "" && LogLevel == "" {
143165
LogLevel = cfg.LogLevel
144166
}
167+
if cfg.InstallDir != "" && InstallDir == "" {
168+
InstallDir = cfg.InstallDir
169+
}
145170
}
146171

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

324+
// Install directory override (empty = ~/.stepsecurity). All
325+
// non-bootstrap files live under this directory: agent.log,
326+
// agent.error.log (+ .prev rotation), ai-agent-hook-errors.jsonl,
327+
// and the binary itself when placed via the loader script.
328+
// Bootstrap config.json keeps living at the legacy ~/.stepsecurity
329+
// path so the agent can always find it. To temporarily override
330+
// for one run, pass --install-dir=PATH or set $STEPSECURITY_HOME.
331+
existing.InstallDir = promptValue(reader, "Install Directory (blank = default)", existing.InstallDir)
332+
299333
// Save
300334
if err := save(existing); err != nil {
301335
return fmt.Errorf("saving configuration: %w", err)
@@ -422,6 +456,7 @@ func ShowConfigure() {
422456
fmt.Printf(" %-24s %s\n", "HTML Output File:", displayValue(cfg.HTMLOutputFile))
423457
}
424458
fmt.Printf(" %-24s %s\n", "Log Level:", displayLogLevel(cfg.LogLevel))
459+
fmt.Printf(" %-24s %s\n", "Install Directory:", displayInstallDir(cfg.InstallDir))
425460
}
426461

427462
func displayValue(v string) string {
@@ -494,6 +529,13 @@ func displayLogLevel(level string) string {
494529
}
495530
}
496531

532+
func displayInstallDir(v string) string {
533+
if v == "" {
534+
return "~/.stepsecurity (default)"
535+
}
536+
return v
537+
}
538+
497539
func isPlaceholder(v string) bool {
498540
return strings.Contains(v, "{{")
499541
}

0 commit comments

Comments
 (0)