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

- **Server-driven scan cadence (run gating)**: on every invocation the agent asks the backend's new `run-directive` endpoint whether a full scan is due and exits quietly when it isn't, with no run-status row, no phases, and a single log line. The scan frequency lives in the StepSecurity dashboard (per tenant, minutes granularity, with a temporary override that auto-reverts at a set time and a per-device "re-scan now" request), so fleets deployed via an external MDM (for example JAMF's hourly cadence) get their real cadence from the backend with no MDM scheduling changes. Run gating is controlled entirely from the backend: the agent always makes the check-in call, and the backend decides. It is gated by a per-environment backend flag so it can be enabled for one environment at a time; while the flag is off the backend answers "scan" every time and the agent behaves exactly as before. Once enabled, gating applies to every device (a 4-hour default when a tenant has not set its own cadence). Any check-in failure fails open to a scan (with a cached-interval fallback so offline machines don't scan every wakeup), and an invocation that lands while another scan is running backs off quietly instead of reporting a failed run. Bypass for debugging: `--force-scan` / `STEPSEC_FORCE_SCAN=1`; per-device kill switch: `STEPSEC_DISABLE_RUN_GATE=1`.

## [1.14.0] - 2026-07-17

### Added
Expand Down
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,7 @@ stepsecurity-dev-machine-guard [COMMAND] [OPTIONS]
| `--include-bundled-plugins` | Include bundled/platform IDE plugins in output |
| `--log-level=LEVEL` | Log level: `error` \| `warn` \| `info` \| `debug` |
| `--verbose` | Shortcut for `--log-level=debug` |
| `--force-scan` | Bypass the server-driven run gate and scan now (enterprise) |
| `--color=WHEN` | Color mode: `auto` \| `always` \| `never` (default: `auto`) |
| `-v`, `--version` | Show version |
| `-h`, `--help` | Show help |
Expand Down Expand Up @@ -214,6 +215,10 @@ count=$(./stepsecurity-dev-machine-guard --json | jq '.summary.mcp_configs_count
# Enterprise: one-time telemetry upload
./stepsecurity-dev-machine-guard send-telemetry

# Enterprise: scan even when the dashboard-managed scan cadence says "not due"
# (equivalent env var: STEPSEC_FORCE_SCAN=1; disable gating: STEPSEC_DISABLE_RUN_GATE=1)
./stepsecurity-dev-machine-guard send-telemetry --force-scan

# Enterprise: remove scheduled scanning
./stepsecurity-dev-machine-guard uninstall
```
Expand Down
51 changes: 47 additions & 4 deletions cmd/stepsecurity-dev-machine-guard/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import (
"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/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/systemd"
Expand Down Expand Up @@ -261,6 +262,14 @@ 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)
}
// 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,
// creates no run-status row, and also skips the post-run hook
// reconcile + policy enforce below. Bypass: --force-scan.
if gateSkipsRun(exec, log, cfg) {
return
}
armExecutionWatchdog(telemetry.ExecutionDeadline(config.MaxExecutionDuration), log)
if err := telemetry.Run(exec, log, cfg); err != nil {
log.Error("%v", err)
Expand Down Expand Up @@ -334,10 +343,18 @@ func main() {
return
}

log.Progress("Sending initial telemetry...")
fmt.Println()
armExecutionWatchdog(telemetry.ExecutionDeadline(config.MaxExecutionDuration), log)
telemetryErr := telemetry.Run(exec, log, cfg)
// Run gate on the inline install scan too. A fresh install is
// unregistered (or long stale) so the backend answers "full" and
// nothing changes; the skip only fires when a re-install lands on a
// freshly-scanned device — where skipping the inline scan is exactly
// right. The scheduler setup above already happened either way.
var telemetryErr error
if !gateSkipsRun(exec, log, cfg) {
log.Progress("Sending initial telemetry...")
fmt.Println()
armExecutionWatchdog(telemetry.ExecutionDeadline(config.MaxExecutionDuration), log)
telemetryErr = telemetry.Run(exec, log, cfg)
}

// On Linux, systemd.Install enabled the timer but did not start it.
// Start it now that the inline scan above has released the singleton
Expand Down Expand Up @@ -474,6 +491,9 @@ func main() {
}
case config.IsEnterpriseMode():
log.Debug("dispatch: enterprise telemetry (auto-detected)")
if gateSkipsRun(exec, log, cfg) {
return
}
armExecutionWatchdog(telemetry.ExecutionDeadline(config.MaxExecutionDuration), log)
if err := telemetry.Run(exec, log, cfg); err != nil {
log.Error("%v", err)
Expand Down Expand Up @@ -627,6 +647,29 @@ func findLegacyLeftovers(legacy string) []string {
// state and reconciles local hook installation to match. Silent no-op
// in community mode (enterprise config missing) — the existing scan
// path stays unaffected. Failures are logged but never crash main.
// gateSkipsRun consults the server-driven run gate. True means this
// invocation must exit 0 without scanning — the backend says the device isn't
// due yet, or another instance is already mid-scan. One Progress line is the
// skip's entire footprint: no beacon, no run-status row, no phases. Every
// gate failure returns false (fail-open), so this can never suppress a scan
// on error.
func gateSkipsRun(exec executor.Executor, log *progress.Logger, cfg *cli.Config) bool {
res := rungate.Evaluate(context.Background(), exec, log, cfg.ForceScan)
if !res.Skip {
log.Progress("Run gate: proceeding with this run (%s)", res.Reason)
// Carry the decision into telemetry.Run so it echoes a line inside the
// captured execution log (the gate runs before log capture starts).
cfg.GateProceedReason = res.Reason
return false
}
if res.Detail != "" {
log.Progress("Run gate: skipping this run (%s) — %s", res.Reason, res.Detail)
} else {
log.Progress("Run gate: skipping this run (%s)", res.Reason)
}
return true
}

// writeHeartbeat stamps last-run.json with this run's start metadata. Wholly
// best-effort: a write failure (read-only home, disabled install dir) is
// logged at debug and never affects the run. The invocation method reuses the
Expand Down
17 changes: 17 additions & 0 deletions internal/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,13 @@ type Config struct {
YarnRCOnly bool // --yarnrc: run only the yarn config audit (both flavors) and render verbose pretty output
SearchDirs []string // defaults to ["$HOME"]

// GateProceedReason is populated at runtime (not a CLI flag) by the run
// gate when it lets a run proceed, so telemetry.Run can echo the gate
// decision into the captured execution log (what "Download logs" shows).
// The gate itself runs before log capture starts, so its live lines never
// reach the downloadable log without this.
GateProceedReason string

// HooksAgent is the --agent value on `hooks install` / `hooks uninstall`;
// "" means "every detected agent".
HooksAgent string
Expand Down Expand Up @@ -76,6 +83,14 @@ type Config struct {
// STEPSECURITY_OVERRIDE_GATE=1.
OverrideGate bool

// ForceScan bypasses the server-driven run gate for this invocation: the
// scan proceeds even when the backend's run-directive says "skip". The
// documented debug/support escape — manual runs are otherwise gated
// exactly like scheduler-fired ones (an MDM-launched run detects as
// one_time too, so invocation method deliberately can't exempt manual
// use). Equivalent env var: STEPSEC_FORCE_SCAN=1.
ForceScan bool

// RulesFile makes the malicious-file detection engine load its RuleSet
// from a local JSON file instead of fetching it from the backend.
// Dev-only — not advertised in --help. Equivalent env var:
Expand Down Expand Up @@ -283,6 +298,8 @@ func Parse(args []string) (*Config, error) {
cfg.Verbose = true
case arg == "--override-gate":
cfg.OverrideGate = true
case arg == "--force-scan":
cfg.ForceScan = true
case strings.HasPrefix(arg, "--rules-file="):
cfg.RulesFile = strings.TrimPrefix(arg, "--rules-file=")
case arg == "--rules-file":
Expand Down
17 changes: 17 additions & 0 deletions internal/device/device.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,23 @@ func Gather(ctx context.Context, exec executor.Executor) model.Device {
}
}

// SerialNumber resolves just the hardware serial (the device_id used by every
// backend API), skipping the rest of Gather. The run gate calls this before
// the device_info phase — and before any network beacon — so it must stay
// self-contained and tolerate every failure by returning "unknown" (the same
// contract as the per-OS getters). Callers cache the result on disk; only a
// device's first gated invocation pays the probe (ioreg on macOS).
func SerialNumber(ctx context.Context, exec executor.Executor) string {
switch exec.GOOS() {
case model.PlatformWindows:
return getSerialNumberWindows(ctx, exec)
case model.PlatformDarwin:
return getSerialNumber(ctx, exec)
default: // linux and other unix
return getSerialNumberLinux(ctx, exec)
}
}

// getSerialNumberWindows and getOSVersionWindows are implemented in
// device_windows.go (native API) and device_other.go (stub).

Expand Down
90 changes: 79 additions & 11 deletions internal/heartbeat/heartbeat.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,11 @@ package heartbeat

import (
"encoding/json"
"errors"
"os"
"path/filepath"
"runtime"
"sync"
"time"

"github.com/step-security/dev-machine-guard/internal/buildinfo"
Expand All @@ -37,9 +39,13 @@ const SchemaVersion = 1
// and diagnostics can reference it without duplicating the literal.
const Filename = "last-run.json"

// Record is the last-run.json envelope: a point-in-time stamp that a run
// began. It deliberately carries only start-of-run facts — outcome lives in
// scan-state.json (LastSuccessfulExecutionID) and agent.error.log.
// Record is the last-run.json envelope. The top-level fields are a
// point-in-time stamp that a run began — start-of-run facts only; the scan
// outcome lives in scan-state.json (LastSuccessfulExecutionID) and
// agent.error.log. RunGate is the run gate's small cache, folded into this
// same file rather than a sibling run-gate-state.json: it is the same "last
// run" kind of data written to the same install dir, and is consulted only on
// the offline fallback path.
type Record struct {
SchemaVersion int `json:"schema_version"`
WrittenAt time.Time `json:"written_at"`
Expand All @@ -48,29 +54,91 @@ type Record struct {
Command string `json:"command"` // subcommand that started the run, e.g. "send-telemetry"
InvocationMethod string `json:"invocation_method"` // scheduler footprint vs manual; see telemetry.DetectInvocationMethod
OS string `json:"os"`
RunGate *RunGate `json:"run_gate,omitempty"`
}

// RunGate is the run gate's cached memory: the resolved device id (so skipped
// wakeups never re-probe the serial), the last completed full run (stamped
// after upload), and the last directive's gating fields. Nil until the first
// successful check-in. Everything here is advisory — a missing, corrupt, or
// future-schema file only costs one serial probe and one fail-open run, never
// a wrong skip. Fields mirror the wire directive; see internal/rungate.
type RunGate struct {
DeviceID string `json:"device_id,omitempty"`
LastFullRunAt int64 `json:"last_full_run_at,omitempty"` // unix sec; stamped on upload success
GatingEnabled bool `json:"gating_enabled,omitempty"`
EffectiveIntervalMinutes int `json:"effective_interval_minutes,omitempty"`
DirectiveFetchedAt int64 `json:"directive_fetched_at,omitempty"` // unix sec of the last successful check-in
}

// mu serializes the read-modify-write writers (Write for the breadcrumb,
// UpdateRunGate for the gate cache). Within a single run they execute
// sequentially on the main goroutine; the mutex is cheap insurance for any
// future concurrent caller. Cross-process safety is atomic-rename
// last-writer-wins, same as internal/state.
var mu sync.Mutex

// Write stamps last-run.json at path with this run's start metadata. An empty
// path is a no-op returning nil — callers pass paths.HeartbeatFile(), which is
// "" when the install dir is disabled (--install-dir=""), and treat the
// heartbeat as off in that case. Best-effort: callers should log a write error
// at debug/warn and continue, never fail the run on it.
//
// It read-modify-writes so the RunGate cache survives: the heartbeat is
// stamped at the very top of every run, before the gate reads that cache on
// the offline path, so a blind overwrite here would erase it every wakeup.
func Write(path, command, invocationMethod string) error {
if path == "" {
return nil
}
rec := Record{
SchemaVersion: SchemaVersion,
WrittenAt: time.Now().UTC(),
PID: os.Getpid(),
AgentVersion: buildinfo.Version,
Command: command,
InvocationMethod: invocationMethod,
OS: runtime.GOOS,
mu.Lock()
defer mu.Unlock()
rec := loadForUpdate(path)
rec.SchemaVersion = SchemaVersion
rec.WrittenAt = time.Now().UTC()
rec.PID = os.Getpid()
rec.AgentVersion = buildinfo.Version
rec.Command = command
rec.InvocationMethod = invocationMethod
rec.OS = runtime.GOOS
return writeRecord(path, rec)
}

// UpdateRunGate applies a read-modify-write to the RunGate cache in the file
// at path, preserving the breadcrumb fields and any RunGate fields the apply
// func leaves untouched. internal/rungate uses it to stamp the last full run
// and record each check-in. Best-effort by contract: on failure the next
// gated invocation simply re-probes / re-checks. Empty path errors so the
// caller can log it.
func UpdateRunGate(path string, apply func(*RunGate)) error {
if path == "" {
return errors.New("heartbeat: no path for run-gate state")
}
mu.Lock()
defer mu.Unlock()
rec := loadForUpdate(path)
rec.SchemaVersion = SchemaVersion
if rec.RunGate == nil {
rec.RunGate = &RunGate{}
}
apply(rec.RunGate)
return writeRecord(path, rec)
}

// loadForUpdate returns the record at path for a read-modify-write, or a zero
// Record when the file is absent, unreadable, corrupt, or a schema mismatch —
// all treated as "start fresh". The breadcrumb must always write, and the gate
// cache is fail-open-safe, so (unlike a versioned config) a newer-schema file
// is overwritten rather than preserved; only a rare downgrade hits that, and
// it costs at most one fail-open run. UNLOCKED — callers hold mu.
func loadForUpdate(path string) Record {
rec, err := Load(path)
if err != nil || rec == nil {
return Record{}
}
return *rec
}

// Load reads last-run.json. A missing file, parse error, or schema mismatch
// returns (nil, err) with err nil for the missing/mismatch cases (expected
// fall-throughs) so callers can treat a nil record as "no usable heartbeat"
Expand Down
36 changes: 36 additions & 0 deletions internal/heartbeat/heartbeat_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -115,3 +115,39 @@ func TestLoadEmptyPathReturnsNilNil(t *testing.T) {
t.Errorf("Load(\"\") = (%+v, %v), want (nil, nil)", rec, err)
}
}

func TestUpdateRunGateEmptyPathErrors(t *testing.T) {
if err := UpdateRunGate("", func(*RunGate) {}); err == nil {
t.Error("UpdateRunGate(\"\") must error so the gate caller logs it")
}
}

// TestWriteAndUpdateRunGatePreserveEachOther is the merge invariant: the
// breadcrumb write keeps the RunGate cache, and the RunGate write keeps the
// breadcrumb (both read-modify-write the one last-run.json).
func TestWriteAndUpdateRunGatePreserveEachOther(t *testing.T) {
path := filepath.Join(t.TempDir(), "last-run.json")

if err := UpdateRunGate(path, func(rg *RunGate) {
rg.DeviceID = "SER123"
rg.LastFullRunAt = 42
rg.GatingEnabled = true
rg.EffectiveIntervalMinutes = 240
}); err != nil {
t.Fatalf("UpdateRunGate: %v", err)
}
if err := Write(path, "send-telemetry", "one_time"); err != nil {
t.Fatalf("Write: %v", err)
}

rec, err := Load(path)
if err != nil || rec == nil {
t.Fatalf("Load: rec=%+v err=%v", rec, err)
}
if rec.Command != "send-telemetry" {
t.Errorf("Write did not stamp the breadcrumb: %+v", rec)
}
if rec.RunGate == nil || rec.RunGate.DeviceID != "SER123" || rec.RunGate.LastFullRunAt != 42 || rec.RunGate.EffectiveIntervalMinutes != 240 {
t.Errorf("Write erased the run-gate cache: %+v", rec.RunGate)
}
}
5 changes: 4 additions & 1 deletion internal/paths/paths.go
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,10 @@ func ScanStateFile() string {

// HeartbeatFile returns the absolute path to last-run.json, or "" when
// Home() is disabled. Callers must treat "" as "heartbeat unavailable" and
// skip writing it (same contract as ScanStateFile).
// skip writing it (same contract as ScanStateFile). Besides the start-of-run
// breadcrumb, this file also holds the run gate's offline cache (see
// heartbeat.RunGate / internal/rungate) — one file for the same "last run"
// data rather than a separate run-gate-state.json.
func HeartbeatFile() string {
home := Home()
if home == "" {
Expand Down
Loading
Loading