Skip to content

Commit dcd8cc2

Browse files
authored
Merge pull request #109 from shubham-stepsecurity/sm/update
fix(install-dir): make config field authoritative
2 parents 1580fa1 + a13a7ad commit dcd8cc2

8 files changed

Lines changed: 312 additions & 49 deletions

File tree

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

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -100,24 +100,32 @@ func main() {
100100
exec := executor.NewReal()
101101

102102
// Install dir resolution (see internal/paths.Home for the canonical
103-
// chain): --install-dir CLI flag > $STEPSECURITY_HOME env var >
104-
// install_dir config field > ~/.stepsecurity default. The CLI flag
105-
// wins because it is the most explicit per-invocation override the
106-
// operator can supply. We feed it to paths via SetOverride; an
107-
// explicit `--install-dir=` (empty) is preserved and short-circuits
108-
// the path computation below to disable file logging for this run.
103+
// chain): --install-dir CLI flag > install_dir config field >
104+
// $STEPSECURITY_HOME env var > ~/.stepsecurity default. Config beats
105+
// env because config.json is the source of truth that the loader
106+
// scripts write to and operators hand-edit; the env var baked into
107+
// service unit files at install time can otherwise become stale.
108+
// An explicit `--install-dir=` (empty) routes through SetDisabled,
109+
// after which paths.Home() returns "" so EVERY on-disk consumer
110+
// (filelog, ai-agent hook errors, any future file) uniformly skips
111+
// — not just file logging. cli.Parse rejects the empty form when
112+
// paired with `install` / `uninstall`, where the platform installers
113+
// need a real on-disk path for unit files and the log directory.
109114
//
110115
// The capture is installed before the logger so every subsequent
111116
// stderr write — including the pipe-tee in
112117
// internal/telemetry/logcapture.go, which nests inside this one —
113118
// flows through to disk.
114119
if cfg.InstallDirSet {
115-
paths.SetOverride(cfg.InstallDir) // may be "" = disabled
120+
if cfg.InstallDir == "" {
121+
paths.SetDisabled()
122+
} else {
123+
paths.SetOverride(cfg.InstallDir)
124+
}
116125
}
117-
installDir := paths.Home()
118-
disabled := cfg.InstallDirSet && cfg.InstallDir == ""
126+
installDir := paths.Home() // "" when SetDisabled or home unresolved
119127
logFilePath := ""
120-
if !disabled && installDir != "" {
128+
if installDir != "" {
121129
logFilePath = filepath.Join(installDir, filelog.Filename)
122130
// Pre-rotate BOTH files unconditionally. In interactive mode the
123131
// stderr rotation is redundant with filelog.Start's own rotation
@@ -164,7 +172,7 @@ func main() {
164172
// auto-move — too risky for v1 (silent overwrites, races with other
165173
// processes, perms changes). Just point at the leftovers.
166174
legacy := paths.LegacyHome()
167-
if !disabled && legacy != "" && installDir != "" && installDir != legacy {
175+
if legacy != "" && installDir != "" && installDir != legacy {
168176
if leftovers := findLegacyLeftovers(legacy); len(leftovers) > 0 {
169177
log.Warn("install dir is %s but the legacy default %s still has files: %s — copy them over manually if you want their history.",
170178
installDir, legacy, strings.Join(leftovers, ", "))

internal/cli/cli.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,17 @@ func Parse(args []string) (*Config, error) {
256256
return nil, fmt.Errorf("--npmrc and --pipconfig are mutually exclusive; pick one")
257257
}
258258

259+
// --install-dir= (explicit empty) disables file logging by routing
260+
// paths.Home() to "" globally. That conflicts with `install` /
261+
// `uninstall`, whose platform installers (systemd / launchd) call
262+
// os.MkdirAll(paths.Home()) and bake STEPSECURITY_HOME into the unit
263+
// file — both break or write nonsense values when Home() is empty.
264+
// Reject the combination here with a clear message rather than
265+
// letting the installer fail opaquely on an empty path.
266+
if cfg.InstallDirSet && cfg.InstallDir == "" && (cfg.Command == "install" || cfg.Command == "uninstall") {
267+
return nil, fmt.Errorf("--install-dir= (empty) cannot be combined with %s — pass a directory or omit the flag", cfg.Command)
268+
}
269+
259270
return cfg, nil
260271
}
261272

internal/cli/cli_test.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package cli
22

33
import (
4+
"strings"
45
"testing"
56
)
67

@@ -363,6 +364,40 @@ func TestParse_InstallDir_AbsentLeavesUnset(t *testing.T) {
363364
}
364365
}
365366

367+
// TestParse_InstallDir_EmptyRejectedForInstall guards against the
368+
// combination that would make systemd/launchd Install mkdir an empty
369+
// path: --install-dir= disables paths.Home() globally, but the install
370+
// command unconditionally calls os.MkdirAll(paths.Home()). Rejecting at
371+
// parse time gives the operator a clear error instead of an opaque
372+
// installer failure.
373+
func TestParse_InstallDir_EmptyRejectedForInstall(t *testing.T) {
374+
for _, cmd := range []string{"install", "uninstall"} {
375+
_, err := Parse([]string{cmd, "--install-dir="})
376+
if err == nil {
377+
t.Errorf("Parse(%q --install-dir=) returned nil error, want rejection", cmd)
378+
continue
379+
}
380+
if !strings.Contains(err.Error(), "--install-dir=") || !strings.Contains(err.Error(), cmd) {
381+
t.Errorf("Parse(%q --install-dir=) error %q should reference the flag and the command", cmd, err)
382+
}
383+
}
384+
}
385+
386+
// TestParse_InstallDir_EmptyAllowedWithoutInstallCommand confirms the
387+
// existing "disable file logging" use case for ad-hoc scans still
388+
// works — the rejection only fires when paired with install/uninstall.
389+
func TestParse_InstallDir_EmptyAllowedWithoutInstallCommand(t *testing.T) {
390+
for _, args := range [][]string{
391+
{"--install-dir="},
392+
{"send-telemetry", "--install-dir="},
393+
{"configure", "show", "--install-dir="},
394+
} {
395+
if _, err := Parse(args); err != nil {
396+
t.Errorf("Parse(%v) returned error %v; --install-dir= should still be valid outside install/uninstall", args, err)
397+
}
398+
}
399+
}
400+
366401
func TestParseHooks_AcceptsInstallDir(t *testing.T) {
367402
cfg, err := Parse([]string{"hooks", "install", "--install-dir=/opt/sec"})
368403
if err != nil {

internal/launchd/launchd.go

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package launchd
22

33
import (
44
"context"
5+
"encoding/xml"
56
"fmt"
67
"os"
78
"strconv"
@@ -14,6 +15,18 @@ import (
1415
"github.com/step-security/dev-machine-guard/internal/progress"
1516
)
1617

18+
// plistEscape XML-escapes a string for use inside a plist <string>
19+
// element. Without this, a path containing &, <, >, ', or " would
20+
// produce malformed XML that launchctl rejects with an opaque parse
21+
// error at load time. text/template's raw substitution does no
22+
// escaping by default — html/template would over-escape unrelated
23+
// content — so we route every templated value through this helper.
24+
func plistEscape(s string) string {
25+
var sb strings.Builder
26+
_ = xml.EscapeText(&sb, []byte(s))
27+
return sb.String()
28+
}
29+
1730
const (
1831
label = "com.stepsecurity.agent"
1932
daemonPlistPath = "/Library/LaunchDaemons/com.stepsecurity.agent.plist"
@@ -114,14 +127,17 @@ func Install(exec executor.Executor, log *progress.Logger) error {
114127
stepHome = paths.Home()
115128
}
116129

117-
// Generate plist
130+
// Generate plist. Every <string>-bound value is XML-escaped so a
131+
// path with &, <, >, ', or " produces a well-formed plist that
132+
// launchctl can actually load. IntervalSeconds is an integer, no
133+
// escape needed; Label is a fixed constant.
118134
plistData := plistTemplateData{
119135
Label: label,
120-
BinaryPath: binaryPath,
136+
BinaryPath: plistEscape(binaryPath),
121137
IntervalSeconds: intervalSeconds,
122-
LogDir: logDir,
123-
UserHome: userHome,
124-
StepSecurityHome: stepHome,
138+
LogDir: plistEscape(logDir),
139+
UserHome: plistEscape(userHome),
140+
StepSecurityHome: plistEscape(stepHome),
125141
}
126142

127143
f, err := os.Create(plistPath)

internal/paths/paths.go

Lines changed: 62 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,21 @@
33
// from a layered set of sources so callers can stop deriving
44
// ~/.stepsecurity independently:
55
//
6-
// 1. --install-dir CLI flag (set by main via SetOverride)
7-
// 2. $STEPSECURITY_HOME environment variable (set by service unit / loader)
8-
// 3. install_dir config field (loaded by internal/config)
6+
// 1. --install-dir CLI flag (set by main via SetOverride / SetDisabled)
7+
// 2. install_dir config field (loaded by internal/config)
8+
// 3. $STEPSECURITY_HOME environment variable (fallback only)
99
// 4. ~/.stepsecurity (legacy default)
1010
//
11+
// Why config beats env: the install_dir field in config.json is the
12+
// canonical source of truth — the loader scripts (agent-api) write to
13+
// it on every run, and operators can hand-edit it. Service installers
14+
// (launchd / systemd / schtasks) bake STEPSECURITY_HOME into their unit
15+
// files at install time, but that snapshot becomes stale the moment the
16+
// operator edits config.json. Letting config win means scheduler-
17+
// invoked runs immediately reflect a config change without requiring
18+
// the operator to re-run `install`. The env var stays as a defensive
19+
// fallback for the rare case where config.json is unreadable.
20+
//
1121
// config.json itself stays at the legacy location regardless — see
1222
// internal/config.LegacyDir — so the agent can always bootstrap. All
1323
// other files (logs, hook errors, the binary placed by the loader) live
@@ -22,46 +32,73 @@ import (
2232
"github.com/step-security/dev-machine-guard/internal/config"
2333
)
2434

25-
// HomeEnvVar is the environment variable consulted in resolution
26-
// step 2. Service installers bake this into their unit files so
27-
// scheduler-invoked runs see the same install dir as interactive ones.
35+
// HomeEnvVar is the environment variable consulted as a defensive
36+
// fallback when both the CLI override and config.InstallDir are unset.
37+
// Service installers bake this into their unit files but its precedence
38+
// is intentionally below config so that an edited install_dir wins.
2839
const HomeEnvVar = "STEPSECURITY_HOME"
2940

3041
// cliOverride captures the --install-dir CLI flag value (step 1).
3142
// Set once at startup by main; never mutated thereafter.
3243
var cliOverride string
3344

45+
// cliDisabled records that --install-dir= (explicit empty) was passed.
46+
// It is distinct from "cliOverride is empty" because the empty string
47+
// is also the "unset" sentinel for cliOverride itself. When set, Home()
48+
// returns "" so every on-disk consumer — filelog, ai-agent hook errors,
49+
// any future file derived from Home() — uniformly skips file output.
50+
var cliDisabled bool
51+
3452
// SetOverride installs the CLI-flag value. Called by main after
3553
// cli.Parse and before any code that calls Home() — see
3654
// cmd/stepsecurity-dev-machine-guard/main.go.
3755
func SetOverride(s string) {
3856
cliOverride = s
57+
cliDisabled = false
58+
}
59+
60+
// SetDisabled marks the install dir as explicitly disabled for this
61+
// run. After this call Home() returns "" regardless of env/config/
62+
// legacy values, so no on-disk artifact is written under the resolved
63+
// install dir. Used by main when the operator passes --install-dir=
64+
// (empty) to silence per-run file logging.
65+
func SetDisabled() {
66+
cliDisabled = true
67+
cliOverride = ""
3968
}
4069

4170
// Home returns the resolved install dir. Falls back to LegacyHome when
42-
// nothing else is set. Empty string is possible only when the home
43-
// directory itself cannot be resolved. A leading $HOME or ~ token in
44-
// any source is expanded via expandHome so the returned path is
45-
// canonical for the current OS, keeping the migration warning in main
46-
// from misfiring on hand-edited values like "$HOME/.stepsecurity" that
47-
// resolve to the legacy default.
48-
//
49-
// Note: this is a superset of resolveSearchDirs in internal/scan/scanner.go,
50-
// which only expands the exact literal "$HOME" — the install dir comes
51-
// from operator-edited config so it has to tolerate the "$HOME/foo" /
52-
// "~/foo" forms our docs use; search_dirs come from --search-dirs flag
53-
// values that operators don't combine with subpaths.
71+
// nothing else is set. Returns "" when SetDisabled() was called or
72+
// when the home directory itself cannot be resolved. A leading $HOME
73+
// or ~ token in any source is expanded via expandHome so the returned
74+
// path is canonical for the current OS; the result is run through
75+
// filepath.Clean so trailing slashes and "." / ".." components don't
76+
// cause spurious mismatches in the migration-warning equality check.
5477
func Home() string {
55-
if cliOverride != "" {
56-
return expandHome(cliOverride)
78+
if cliDisabled {
79+
return ""
5780
}
58-
if v := os.Getenv(HomeEnvVar); v != "" {
59-
return expandHome(v)
81+
// Read the env var once so a concurrent mutation (test helpers,
82+
// future goroutine setup) can't flip the value between the switch
83+
// guard and the assignment below — and so we don't pay for a second
84+
// syscall on every lookup.
85+
envHome := os.Getenv(HomeEnvVar)
86+
var raw string
87+
switch {
88+
case cliOverride != "":
89+
raw = cliOverride
90+
case config.InstallDir != "":
91+
raw = config.InstallDir
92+
case envHome != "":
93+
raw = envHome
94+
default:
95+
return LegacyHome()
6096
}
61-
if config.InstallDir != "" {
62-
return expandHome(config.InstallDir)
97+
resolved := expandHome(raw)
98+
if resolved == "" {
99+
return ""
63100
}
64-
return LegacyHome()
101+
return filepath.Clean(resolved)
65102
}
66103

67104
// expandHome replaces a leading $HOME or ~ token with the resolved

0 commit comments

Comments
 (0)