Skip to content
Open
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
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,19 @@ 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.

## [Unreleased]

### Added

- **Telemetry scans run at OS background priority**: at the start of every enterprise scan the agent drops itself — and, on macOS and Linux, every child process it spawns — into the platform's background CPU/IO band (macOS `PRIO_DARWIN_BG`, the Time Machine/Spotlight tier; Linux `nice 19` plus best-effort ionice priority 7; Windows below-normal priority class plus background IO/memory mode), so disk-heavy phases no longer compete with the interactive user. Every mechanism is throttled-but-guaranteed-progress, never an idle-only class that could starve under sustained load, and the existing per-phase budgets and scan deadline bound the worst case. Interactive community `scan` runs are unaffected. Per-device escape hatch: `STEPSEC_DISABLE_BACKGROUND_PRIORITY=1`.
- **Self-updating binary for auto-loader installs**: scheduler-fired runs (launchd/systemd now launch the binary directly instead of the loader script) keep themselves current: on every tick the binary asks the latest-binary endpoint for the release its tenant should run, verifies the checksum's Ed25519 SSHSIG natively (stdlib crypto, same pinned release key and `stepsecurity-mdm-checksum` namespace the loaders verify with ssh-keygen), downloads the asset, verifies its sha256, and atomically swaps its own executable — the new version takes effect on the next scheduled fire. Strictly opt-in via the `auto_update` config key the auto-loader writes at install; version-pinned installs and manual runs never set it and can never drift off their pin. Script-baked update-policy overrides persist as `update_lag_behind`/`update_cooldown_hours` config keys so the binary sends the same query params the loader did. Update failures never block a scan. Kill switch: `STEPSEC_DISABLE_SELF_UPDATE=1`. Windows keeps its task.exe + loader architecture.
- **config.json travels with the install dir**: the agent now resolves config.json binary-relative first — next to the binary, then one directory up (the loader layout is `<install_dir>/bin/<binary>` with `<install_dir>/config.json`) — before the legacy `~/.stepsecurity` fallback, and `configure`-style writes follow the file that was read. Combined with the loader change that writes config.json into the install directory (keeping a legacy compatibility copy refreshed for older binaries), a custom Install Directory now holds the binary, logs, state, and configuration in one tree instead of leaving config.json behind in the user home. Default installs are byte-identical (the binary already lives under `~/.stepsecurity/bin`).
- **Sleep-spanning runs are detected and reported**: when the machine sleeps mid-scan, wall-clock and monotonic elapsed time diverge (the monotonic clock halts during sleep on macOS/Linux); the agent now reports that divergence as `slept_ms` per completed phase and per run in run-status heartbeats and the final telemetry payload, and logs "system slept ~Xm" warnings plus a run-summary note. Report-only — phase durations stay monotonic (actual work time) and the scan is never aborted. Divergence under 60 seconds is ignored to absorb NTP clock steps.

### Fixed

- **Tool lookups no longer hang on interactive zsh prompts**: the per-tool `which` login shells (AI CLI detection and friends) could block forever when a user's rc files hit an interactive read — seen in the field as a scan stuck on `which claude` because zsh compinit's "insecure directories … continue [y] or abort [n]?" prompt read the terminal the agent inherited. Two-layer fix: every Unix child now runs in its own session with no controlling terminal (`Setsid`), so TTY prompts can't block; and user-PATH tool resolution is native Go — the login shell is spawned once per run to capture `$PATH`, then every lookup is a stat against those directories instead of its own rc-sourcing shell.

## [1.16.0] - 2026-08-20

### Added
Expand Down
15 changes: 12 additions & 3 deletions cmd/stepsecurity-dev-machine-guard/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import (
"github.com/step-security/dev-machine-guard/internal/rungate"
"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/selfupdate"
"github.com/step-security/dev-machine-guard/internal/systemd"
"github.com/step-security/dev-machine-guard/internal/tcc"
"github.com/step-security/dev-machine-guard/internal/telemetry"
Expand Down Expand Up @@ -73,7 +74,8 @@ func main() {
os.Exit(aiagentscli.RunHook(os.Stdin, os.Stdout, os.Stderr, os.Args[2:]))
}

// Load persisted config (~/.stepsecurity/config.json) before parsing CLI
// Load persisted config (config.json from the install dir, falling back
// to ~/.stepsecurity — see internal/config.readConfigDir) before parsing CLI
config.Load()

cfg, err := cli.Parse(os.Args[1:])
Expand Down Expand Up @@ -262,6 +264,12 @@ func main() {
log.Error("Enterprise configuration not found. Run '%s configure' or download the script from your StepSecurity dashboard.", os.Args[0])
os.Exit(1)
}
// Self-update BEFORE the run gate so a gated-skip tick still keeps
// the binary current, exactly like the loader-periodic flow updated
// on every tick regardless of whether a scan ran. No-op unless the
// install opted in (config auto_update, written by the auto-loader);
// a swapped binary takes effect on the NEXT scheduled fire.
selfupdate.Run(context.Background(), exec, log)
// Server-driven run gate: exit 0 quietly when the backend says this
// invocation isn't due (or another instance is mid-scan). Sits before
// the watchdog and telemetry.Run so a skipped wakeup posts no beacon,
Expand Down Expand Up @@ -659,8 +667,9 @@ func scanJSONEncoder(w io.Writer) *json.Encoder {
// 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).
// excluded — loaders keep a compatibility copy refreshed there for
// binaries that predate the binary-relative config lookup, so its
// presence is expected and not a leftover to migrate).
func findLegacyLeftovers(legacy string) []string {
candidates := []string{
"agent.error.log",
Expand Down
28 changes: 28 additions & 0 deletions internal/bgpriority/apply_darwin.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
//go:build darwin

package bgpriority

import (
"fmt"
"syscall"
)

// Darwin-specific setpriority(2) selectors, spelled out because
// golang.org/x/sys/unix doesn't define them (see sys/resource.h).
const (
// prioDarwinProcess scopes the call to a whole process (PRIO_DARWIN_PROCESS).
prioDarwinProcess = 4
// prioDarwinBG places the process in the Darwin background band
// (PRIO_DARWIN_BG): throttled CPU, disk IO, and network — the tier Time
// Machine and Spotlight indexing run at. Throttled means a reduced
// proportional share under contention, never starvation, and effectively
// full speed on an otherwise idle machine.
prioDarwinBG = 0x1000
)

func apply() (string, error) {
if err := syscall.Setpriority(prioDarwinProcess, 0, prioDarwinBG); err != nil {
return "", fmt.Errorf("setpriority(PRIO_DARWIN_PROCESS, PRIO_DARWIN_BG): %w", err)
}
return "macOS background task policy (throttled CPU/IO/network, inherited by child processes)", nil
}
92 changes: 92 additions & 0 deletions internal/bgpriority/apply_linux.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
//go:build linux

package bgpriority

import (
"fmt"
"os"
"strconv"
"strings"
"syscall"
)

// ioprio_set(2) encoding (linux/ioprio.h), spelled out because neither the
// stdlib nor golang.org/x/sys/unix wraps the call itself.
const (
ioprioWhoProcess = 1 // IOPRIO_WHO_PROCESS: with a tid, targets that thread
ioprioClassShift = 13
// ioprioClassBE / ioprioBELowest: best-effort class, lowest slot.
// Deliberately NOT the idle class (3): idle IO is only serviced when the
// disk is otherwise quiet and can be starved indefinitely under a
// sustained workload (a running build), whereas best-effort prio 7 is the
// last slot in the round-robin and always makes progress.
ioprioClassBE = 2
ioprioBELowest = 7
)

// apply lowers CPU and IO priority for EVERY current OS thread. On Linux both
// setpriority(PRIO_PROCESS, 0, …) and ioprio_set(IOPRIO_WHO_PROCESS, 0, …)
// act on the CALLING THREAD only (nice is per-task under NPTL), and the Go
// runtime multiplexes goroutines across many threads — a single self-targeted
// call would leave most scan work, and any child forked from another thread,
// at normal priority. Walking /proc/self/task covers every live thread;
// threads and child processes created afterwards inherit from their (covered)
// creator, so the whole process stays in the background band.
func apply() (string, error) {
tids, err := os.ReadDir("/proc/self/task")
if err != nil {
// Fall back to the calling thread — degraded but not useless.
return applyToThread(0)
}

niceCovered, ioCovered := 0, 0
for _, ent := range tids {
tid, convErr := strconv.Atoi(ent.Name())
if convErr != nil {
continue
}
// A thread can exit between the ReadDir and the calls (ESRCH) —
// count only what actually applied.
if syscall.Setpriority(syscall.PRIO_PROCESS, tid, 19) == nil {
niceCovered++
}
ioprio := uintptr(ioprioClassBE<<ioprioClassShift | ioprioBELowest)
// #nosec G115 -- tid parses from a /proc/self/task entry name: a
// positive kernel thread id, always far below uintptr's range.
if _, _, errno := syscall.Syscall(syscall.SYS_IOPRIO_SET, ioprioWhoProcess, uintptr(tid), ioprio); errno == 0 {
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
ioCovered++
}
}

var parts []string
if niceCovered > 0 {
parts = append(parts, fmt.Sprintf("nice 19 on %d/%d threads", niceCovered, len(tids)))
}
if ioCovered > 0 {
parts = append(parts, fmt.Sprintf("ionice best-effort 7 on %d/%d threads", ioCovered, len(tids)))
}
// Partial success still helps; report only what actually applied.
if len(parts) == 0 {
return "", fmt.Errorf("setpriority/ioprio_set applied to no thread")
}
return strings.Join(parts, ", ") + " (inherited by new threads and child processes)", nil
}

// applyToThread is the single-target fallback when /proc is unavailable.
func applyToThread(tid int) (string, error) {
var parts []string
niceErr := syscall.Setpriority(syscall.PRIO_PROCESS, tid, 19)
if niceErr == nil {
parts = append(parts, "nice 19")
}
ioprio := uintptr(ioprioClassBE<<ioprioClassShift | ioprioBELowest)
// #nosec G115 -- tid is 0 (calling thread) on this fallback path.
_, _, errno := syscall.Syscall(syscall.SYS_IOPRIO_SET, ioprioWhoProcess, uintptr(tid), ioprio)
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
if errno == 0 {
parts = append(parts, "ionice best-effort 7")
}
if len(parts) == 0 {
return "", fmt.Errorf("setpriority: %v; ioprio_set: %v", niceErr, errno)
}
return strings.Join(parts, ", ") + " (calling thread only; /proc/self/task unavailable)", nil
}
9 changes: 9 additions & 0 deletions internal/bgpriority/apply_other.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
//go:build !darwin && !linux && !windows

package bgpriority

// apply is a no-op on platforms without a supported priority mechanism;
// Apply logs nothing (empty desc, nil error).
func apply() (string, error) {
return "", nil
}
36 changes: 36 additions & 0 deletions internal/bgpriority/apply_windows.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
//go:build windows

package bgpriority

import (
"fmt"
"strings"

"golang.org/x/sys/windows"
)

func apply() (string, error) {
h := windows.CurrentProcess()
var parts []string

// Class FIRST: the priority class can't be changed once the process is
// in background mode, and child processes of a BELOW_NORMAL parent
// inherit the class (background mode itself is never inherited).
classErr := windows.SetPriorityClass(h, windows.BELOW_NORMAL_PRIORITY_CLASS)
if classErr == nil {
parts = append(parts, "below-normal CPU class (inherited by children)")
}

// Background mode lowers this process's IO priority to Very Low (the
// defrag/indexer tier — deprioritized behind normal IO but continuously
// serviced) and its memory priority to the lowest band.
bgErr := windows.SetPriorityClass(h, windows.PROCESS_MODE_BACKGROUND_BEGIN)
if bgErr == nil {
parts = append(parts, "background IO/memory mode")
}

if len(parts) == 0 {
return "", fmt.Errorf("SetPriorityClass(BELOW_NORMAL): %v; SetPriorityClass(BACKGROUND_BEGIN): %v", classErr, bgErr)
}
return strings.Join(parts, ", "), nil
}
44 changes: 44 additions & 0 deletions internal/bgpriority/bgpriority.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// Package bgpriority moves the current process into the operating system's
// background CPU/IO priority band so telemetry scans never compete with the
// interactive user for disk or CPU. Every mechanism used is throttled-but-
// guaranteed-progress (the tier Time Machine, Spotlight, and defrag run at),
// never an idle-only class that could be starved indefinitely under sustained
// load — so a scan on a busy machine gets slower, not stuck; the existing
// per-phase budgets and scan deadline bound the worst case.
//
// Inheritance: macOS task policy and Linux nice/ioprio are inherited across
// fork/exec, so every subprocess the scan spawns (npm ls, brew, spctl, ...)
// runs in the same band. On Windows, children inherit the below-normal CPU
// class but not the process's background IO mode.
package bgpriority

import (
"github.com/step-security/dev-machine-guard/internal/executor"
"github.com/step-security/dev-machine-guard/internal/progress"
)

// EnvDisable is the per-device escape hatch: set to "1" to keep the scan at
// normal priority (e.g. while timing a scan, or if a fleet's machines are
// dedicated build hosts where nothing interactive competes).
const EnvDisable = "STEPSEC_DISABLE_BACKGROUND_PRIORITY"

// applyImpl is the per-OS implementation; a var so tests can intercept it.
var applyImpl = apply

// Apply lowers the current process (and, where the OS inherits it, its
// children) to background priority. Best-effort by contract: failure to
// apply must never fail the run, so errors are logged and swallowed.
func Apply(exec executor.Executor, log *progress.Logger) {
if exec.Getenv(EnvDisable) == "1" {
log.Debug("background priority: disabled via %s", EnvDisable)
return
}
desc, err := applyImpl()
if err != nil {
log.Warn("background priority: not applied (%v) — continuing at normal priority", err)
return
}
if desc != "" {
log.Progress("Running at background priority: %s (disable with %s=1)", desc, EnvDisable)
}
}
82 changes: 82 additions & 0 deletions internal/bgpriority/bgpriority_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
package bgpriority

import (
"errors"
"testing"

"github.com/step-security/dev-machine-guard/internal/executor"
"github.com/step-security/dev-machine-guard/internal/progress"
)

// swapApply replaces the per-OS implementation for one test and restores it.
func swapApply(t *testing.T, fn func() (string, error)) *int {
t.Helper()
calls := 0
orig := applyImpl
applyImpl = func() (string, error) {
calls++
return fn()
}
t.Cleanup(func() { applyImpl = orig })
return &calls
}

func TestApply_EscapeHatchSkipsImplementation(t *testing.T) {
mock := executor.NewMock()
mock.SetEnv(EnvDisable, "1")
calls := swapApply(t, func() (string, error) { return "should not run", nil })

Apply(mock, progress.NewLogger(progress.LevelInfo))

if *calls != 0 {
t.Errorf("applyImpl called %d times with %s=1, want 0", *calls, EnvDisable)
}
}

func TestApply_ErrorIsToleratedAndDoesNotPanic(t *testing.T) {
mock := executor.NewMock()
calls := swapApply(t, func() (string, error) { return "", errors.New("EPERM") })

Apply(mock, progress.NewLogger(progress.LevelInfo))

if *calls != 1 {
t.Errorf("applyImpl called %d times, want 1", *calls)
}
}

func TestApply_AppliedPathInvokesImplementationOnce(t *testing.T) {
tests := []struct {
name string
env string
}{
{name: "env unset", env: ""},
{name: "env set to non-1", env: "0"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
mock := executor.NewMock()
if tc.env != "" {
mock.SetEnv(EnvDisable, tc.env)
}
calls := swapApply(t, func() (string, error) { return "test band", nil })

Apply(mock, progress.NewLogger(progress.LevelInfo))

if *calls != 1 {
t.Errorf("applyImpl called %d times, want 1", *calls)
}
})
}
}

func TestApply_NoOpPlatformDescLogsNothing(t *testing.T) {
mock := executor.NewMock()
calls := swapApply(t, func() (string, error) { return "", nil })

// Empty desc + nil error is the apply_other contract; Apply must accept it.
Apply(mock, progress.NewLogger(progress.LevelInfo))

if *calls != 1 {
t.Errorf("applyImpl called %d times, want 1", *calls)
}
}
Loading
Loading