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
47 changes: 43 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,25 @@ 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 {
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
10 changes: 10 additions & 0 deletions internal/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,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 +291,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
23 changes: 23 additions & 0 deletions internal/lock/lock.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,26 @@ func (l *Lock) Release() {
_ = os.Remove(l.path)
}
}

// Holder peeks at the lock without acquiring or mutating it: it reports the
// recorded PID and whether that process is alive. (0, false) means no live
// holder — absent file, unparsable content, or a stale PID. The run gate uses
// this to back off QUIETLY when a scan is already running (hourly MDM
// wakeups overlapping a 1-2h scan), instead of reaching Acquire's contention
// path, which reports a failed run to the backend. TOCTOU is fine here:
// Acquire's O_CREATE|O_EXCL remains the authoritative guard for the race
// window, and a racer just keeps today's behavior.
func Holder() (pid int, alive bool) {
data, err := os.ReadFile(lockFilePath)
if err != nil {
return 0, false
}
pid, err = strconv.Atoi(strings.TrimSpace(string(data)))
if err != nil || pid <= 0 {
return 0, false
}
if !isProcessAlive(pid) {
return 0, false
}
return pid, true
}
83 changes: 83 additions & 0 deletions internal/lock/lock_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package lock

import (
"fmt"
"os"
"path/filepath"
"testing"
)

// withTempLockPath redirects the package-level lock path to a tempdir so
// tests never touch the real /tmp lock of a concurrently-running agent.
func withTempLockPath(t *testing.T) string {
t.Helper()
prev := lockFilePath
lockFilePath = filepath.Join(t.TempDir(), "test.lock")
t.Cleanup(func() { lockFilePath = prev })
return lockFilePath
}

func TestHolderAbsentFile(t *testing.T) {
withTempLockPath(t)
if pid, alive := Holder(); alive || pid != 0 {
t.Fatalf("Holder() = (%d, %v), want (0, false) with no lock file", pid, alive)
}
}

func TestHolderLivePID(t *testing.T) {
path := withTempLockPath(t)
// Our own PID is guaranteed alive.
if err := os.WriteFile(path, fmt.Appendf(nil, "%d", os.Getpid()), 0o600); err != nil {
t.Fatal(err)
}
pid, alive := Holder()
if !alive || pid != os.Getpid() {
t.Fatalf("Holder() = (%d, %v), want (%d, true)", pid, alive, os.Getpid())
}
}

func TestHolderStalePID(t *testing.T) {
path := withTempLockPath(t)
// A PID far beyond any real pid space reads as dead on every platform.
if err := os.WriteFile(path, []byte("1073741824"), 0o600); err != nil {
t.Fatal(err)
}
if pid, alive := Holder(); alive || pid != 0 {
t.Fatalf("Holder() = (%d, %v), want (0, false) for a stale PID", pid, alive)
}
}

func TestHolderGarbageContent(t *testing.T) {
path := withTempLockPath(t)
if err := os.WriteFile(path, []byte("not-a-pid\n"), 0o600); err != nil {
t.Fatal(err)
}
if pid, alive := Holder(); alive || pid != 0 {
t.Fatalf("Holder() = (%d, %v), want (0, false) for garbage content", pid, alive)
}
}

// TestHolderReflectsAcquireRelease pins the peek to the real lock lifecycle:
// alive while held (it is this process), gone after Release, and Holder
// itself never mutates the file.
func TestHolderReflectsAcquireRelease(t *testing.T) {
withTempLockPath(t)

lk, err := Acquire(nil)
if err != nil {
t.Fatalf("Acquire: %v", err)
}
pid, alive := Holder()
if !alive || pid != os.Getpid() {
t.Fatalf("Holder() during hold = (%d, %v), want (%d, true)", pid, alive, os.Getpid())
}
// Peeking must not release or corrupt the lock.
if _, err := Acquire(nil); err == nil {
t.Fatal("second Acquire must fail while the lock is held")
}

lk.Release()
if pid, alive := Holder(); alive || pid != 0 {
t.Fatalf("Holder() after Release = (%d, %v), want (0, false)", pid, alive)
}
}
12 changes: 12 additions & 0 deletions internal/paths/paths.go
Original file line number Diff line number Diff line change
Expand Up @@ -161,3 +161,15 @@ func HeartbeatFile() string {
}
return filepath.Join(home, "last-run.json")
}

// RunGateStateFile returns the absolute path to run-gate-state.json (the run
// gate's cached directive + last-full-run stamp), or "" when Home() is
// disabled. Callers must treat "" as "gating state unavailable" and fail open
// (same contract as ScanStateFile).
func RunGateStateFile() string {
home := Home()
if home == "" {
return ""
}
return filepath.Join(home, "run-gate-state.json")
}
91 changes: 91 additions & 0 deletions internal/rungate/client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
package rungate

import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"

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

// checkinTimeout caps the whole check-in round-trip. The gate runs before any
// beacon on every scheduler wakeup, so it must give up fast and fail open —
// an offline laptop pays this once per wakeup.
const checkinTimeout = 5 * time.Second

// maxDirectiveBytes bounds the response read. A directive is ~200 bytes;
// anything near the cap is not our backend.
const maxDirectiveBytes = 64 << 10

// Checkin asks the backend whether this device is due for a full run:
// GET /v1/{customer}/developer-mdm-agent/run-directive?device_id=…[&last_run_at=…]
// lastRunAt (unix seconds, 0 = unknown) is the agent's own last successful
// upload stamp, sent as insurance against lost or laggy ingest on the backend
// side. Errors are redacted (the URL embeds the customer id and the header
// carries the tenant key). A near-verbatim sibling of rules/fetch.go.
func Checkin(ctx context.Context, endpoint, apiKey, customerID, deviceID string, lastRunAt int64) (Directive, error) {
endpoint = strings.TrimSpace(endpoint)
apiKey = strings.TrimSpace(apiKey)
if endpoint == "" || apiKey == "" {
return Directive{}, errors.New("rungate: missing endpoint or api key")
}
if strings.TrimSpace(customerID) == "" {
return Directive{}, errors.New("rungate: empty customer_id")
}
if strings.TrimSpace(deviceID) == "" {
return Directive{}, errors.New("rungate: empty device_id")
}

target := strings.TrimRight(endpoint, "/") +
"/v1/" + url.PathEscape(customerID) +
"/developer-mdm-agent/run-directive?device_id=" + url.QueryEscape(deviceID)
if lastRunAt > 0 {
target += "&last_run_at=" + strconv.FormatInt(lastRunAt, 10)
}

ctx, cancel := context.WithTimeout(ctx, checkinTimeout)
defer cancel()

req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
if err != nil {
return Directive{}, fmt.Errorf("rungate: build request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+apiKey)
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", "dmg/"+buildinfo.Version)

resp, err := (&http.Client{Timeout: checkinTimeout}).Do(req)
if err != nil {
return Directive{}, fmt.Errorf("rungate: transport: %s", redact.String(err.Error()))
}
defer func() { _ = resp.Body.Close() }()

if resp.StatusCode != http.StatusOK {
snippet, _ := io.ReadAll(io.LimitReader(resp.Body, maxDirectiveBytes))
return Directive{}, fmt.Errorf("rungate: unexpected status %d: %s",
resp.StatusCode, redact.String(strings.TrimSpace(string(snippet))))
}

body, err := io.ReadAll(io.LimitReader(resp.Body, maxDirectiveBytes))
if err != nil {
return Directive{}, fmt.Errorf("rungate: read body: %w", err)
}
var env directiveEnvelope
if err := json.Unmarshal(body, &env); err != nil {
return Directive{}, fmt.Errorf("rungate: decode body: %w", err)
}
// A 200 with no directive object is an unknown shape — surface it as an
// error so the caller fails open rather than trusting a zero value.
if env.Directive.Mode == "" {
return Directive{}, errors.New("rungate: response carried no directive")
}
return env.Directive, nil
}
Loading
Loading