diff --git a/CHANGELOG.md b/CHANGELOG.md index b25ec138..b2c7aa83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 7bcc9b2d..620e7a4d 100644 --- a/README.md +++ b/README.md @@ -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 | @@ -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 ``` diff --git a/cmd/stepsecurity-dev-machine-guard/main.go b/cmd/stepsecurity-dev-machine-guard/main.go index c6213344..c87c130c 100644 --- a/cmd/stepsecurity-dev-machine-guard/main.go +++ b/cmd/stepsecurity-dev-machine-guard/main.go @@ -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" @@ -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) @@ -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 @@ -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) @@ -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 diff --git a/internal/cli/cli.go b/internal/cli/cli.go index e175ba55..eb3f6c43 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -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: @@ -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": diff --git a/internal/device/device.go b/internal/device/device.go index 58f76b15..058afb87 100644 --- a/internal/device/device.go +++ b/internal/device/device.go @@ -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). diff --git a/internal/lock/lock.go b/internal/lock/lock.go index 828163cb..b1e14b76 100644 --- a/internal/lock/lock.go +++ b/internal/lock/lock.go @@ -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 +} diff --git a/internal/lock/lock_test.go b/internal/lock/lock_test.go new file mode 100644 index 00000000..e3e9db58 --- /dev/null +++ b/internal/lock/lock_test.go @@ -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) + } +} diff --git a/internal/paths/paths.go b/internal/paths/paths.go index fef66482..200b169b 100644 --- a/internal/paths/paths.go +++ b/internal/paths/paths.go @@ -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") +} diff --git a/internal/rungate/client.go b/internal/rungate/client.go new file mode 100644 index 00000000..e3c1ac3e --- /dev/null +++ b/internal/rungate/client.go @@ -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 +} diff --git a/internal/rungate/client_test.go b/internal/rungate/client_test.go new file mode 100644 index 00000000..0f84cb09 --- /dev/null +++ b/internal/rungate/client_test.go @@ -0,0 +1,119 @@ +package rungate + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +func TestCheckinParsesDirectiveAndSendsParams(t *testing.T) { + var gotPath, gotQuery, gotAuth string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotQuery = r.URL.RawQuery + gotAuth = r.Header.Get("Authorization") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"directive":{"mode":"skip","reason":"not_due","gating_enabled":true,"effective_interval_minutes":240,"next_eligible_at":1753164400,"checked_at":1753160800}}`)) + })) + defer srv.Close() + + d, err := Checkin(context.Background(), srv.URL, "tenant-key", "acme corp", "SER 123", 1753150000) + if err != nil { + t.Fatalf("Checkin: %v", err) + } + if !d.ShouldSkip() || d.Reason != "not_due" || d.EffectiveIntervalMinutes != 240 || d.NextEligibleAt != 1753164400 { + t.Fatalf("directive = %+v", d) + } + if gotPath != "/v1/acme%20corp/developer-mdm-agent/run-directive" && gotPath != "/v1/acme corp/developer-mdm-agent/run-directive" { + t.Errorf("path = %q (customer id must be path-escaped)", gotPath) + } + if !strings.Contains(gotQuery, "device_id=SER+123") && !strings.Contains(gotQuery, "device_id=SER%20123") { + t.Errorf("query = %q, want escaped device_id", gotQuery) + } + if !strings.Contains(gotQuery, "last_run_at=1753150000") { + t.Errorf("query = %q, want last_run_at", gotQuery) + } + if gotAuth != "Bearer tenant-key" { + t.Errorf("Authorization = %q", gotAuth) + } +} + +func TestCheckinOmitsZeroLastRunAt(t *testing.T) { + var gotQuery string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotQuery = r.URL.RawQuery + _, _ = w.Write([]byte(`{"directive":{"mode":"full","reason":"gating_disabled"}}`)) + })) + defer srv.Close() + + if _, err := Checkin(context.Background(), srv.URL, "k", "acme", "SER1", 0); err != nil { + t.Fatalf("Checkin: %v", err) + } + if strings.Contains(gotQuery, "last_run_at") { + t.Errorf("query %q must omit last_run_at when unknown", gotQuery) + } +} + +func TestCheckinErrorPaths(t *testing.T) { + tests := []struct { + name string + handler http.HandlerFunc + }{ + {name: "401", handler: func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusUnauthorized) }}, + {name: "404 old backend", handler: func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) }}, + {name: "500", handler: func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) }}, + {name: "garbage body", handler: func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte("nope")) }}, + {name: "no directive object", handler: func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte(`{}`)) }}, + {name: "empty mode", handler: func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte(`{"directive":{"reason":"x"}}`)) }}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + srv := httptest.NewServer(tt.handler) + defer srv.Close() + if _, err := Checkin(context.Background(), srv.URL, "k", "acme", "SER1", 0); err == nil { + t.Fatal("Checkin must error so the gate fails open") + } + }) + } +} + +func TestCheckinRespectsContextDeadline(t *testing.T) { + release := make(chan struct{}) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + <-release + })) + defer srv.Close() + defer close(release) + + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + start := time.Now() + _, err := Checkin(ctx, srv.URL, "k", "acme", "SER1", 0) + if err == nil { + t.Fatal("Checkin must error on deadline") + } + if elapsed := time.Since(start); elapsed > 3*time.Second { + t.Fatalf("Checkin took %v; the caller's deadline must bound it", elapsed) + } +} + +func TestCheckinValidatesInputs(t *testing.T) { + for _, tt := range []struct { + name string + endpoint, key, customerID, deviceID string + }{ + {name: "no endpoint", endpoint: "", key: "k", customerID: "c", deviceID: "d"}, + {name: "no key", endpoint: "http://x", key: "", customerID: "c", deviceID: "d"}, + {name: "no customer", endpoint: "http://x", key: "k", customerID: " ", deviceID: "d"}, + {name: "no device", endpoint: "http://x", key: "k", customerID: "c", deviceID: ""}, + } { + t.Run(tt.name, func(t *testing.T) { + if _, err := Checkin(context.Background(), tt.endpoint, tt.key, tt.customerID, tt.deviceID, 0); err == nil { + t.Fatal("want validation error") + } + }) + } +} diff --git a/internal/rungate/decide.go b/internal/rungate/decide.go new file mode 100644 index 00000000..5c2a4f0c --- /dev/null +++ b/internal/rungate/decide.go @@ -0,0 +1,93 @@ +package rungate + +import ( + "time" +) + +// offlineCacheMinFloor is the minimum freshness window for the cached +// directive when the backend is unreachable. The window is +// max(offlineCacheMinFloor, 2×interval): generous because the cache stores an +// interval (recomputed against LastFullRunAt every wakeup), never a literal +// skip — so even a week-old cache can only delay one interval, and expiring +// it merely reverts to today's scan-every-wakeup behavior. +const offlineCacheMinFloor = 7 * 24 * time.Hour + +// Inputs is everything Decide consumes. Pure data — the orchestration in +// Evaluate gathers it; tests construct it directly. +// +// There is deliberately no agent-side feature flag here: the feature is +// controlled entirely by the backend's run-directive response (dormant when +// the backend answers "always scan"), so it can be turned on or off from the +// backend without shipping a new agent. +type Inputs struct { + // ForceScan: --force-scan / STEPSEC_FORCE_SCAN=1. + ForceScan bool + // KillSwitch: STEPSEC_DISABLE_RUN_GATE=1 (per-device local escape). + KillSwitch bool + // LockHeldByLivePID: another instance is mid-scan (lock.Holder). + LockHeldByLivePID bool + // Directive is the backend's answer; nil when the check-in failed. + Directive *Directive + // State is the on-disk cache; nil when absent/corrupt/future-schema. + State *State + Now time.Time +} + +// Decision is the gate's verdict. Reason values are log-facing: +// forced | kill_switch | lock_held | directive (server answer, subreason +// attached by the caller) | offline_cache_skip | offline_fail_open. +type Decision struct { + Skip bool + Reason string + NextEligibleAt int64 // unix sec; informational, set on skips when known +} + +// Decide applies the gate's precedence. Pure. +// +// Order matters: the local escapes (force, kill switch) win over everything; +// the lock peek beats the directive so an overlapping wakeup during a long +// scan backs off quietly WITHOUT a network call (Evaluate orders the I/O the +// same way); the server directive beats the cache; the cache only speaks when +// the backend didn't. +func Decide(in Inputs) Decision { + if in.ForceScan { + return Decision{Skip: false, Reason: "forced"} + } + if in.KillSwitch { + return Decision{Skip: false, Reason: "kill_switch"} + } + if in.LockHeldByLivePID { + return Decision{Skip: true, Reason: "lock_held"} + } + if d := in.Directive; d != nil { + if d.ShouldSkip() { + return Decision{Skip: true, Reason: "directive:" + d.Reason, NextEligibleAt: d.NextEligibleAt} + } + return Decision{Skip: false, Reason: "directive:" + d.Reason} + } + return decideOffline(in.State, in.Now) +} + +// decideOffline is the check-in-failed path: replay the cached interval +// against the local last-full-run stamp. Every missing precondition fails +// open — no cache, gating off at last contact, no interval, never completed a +// run, or a cache older than max(floor, 2×interval). +func decideOffline(st *State, now time.Time) Decision { + if st == nil || !st.GatingEnabled || st.EffectiveIntervalMinutes <= 0 || st.LastFullRunAt <= 0 { + return Decision{Skip: false, Reason: "offline_fail_open"} + } + interval := time.Duration(st.EffectiveIntervalMinutes) * time.Minute + staleness := max(2*interval, offlineCacheMinFloor) + if st.DirectiveFetchedAt <= 0 || now.Sub(time.Unix(st.DirectiveFetchedAt, 0)) >= staleness { + return Decision{Skip: false, Reason: "offline_fail_open"} + } + sinceLast := now.Sub(time.Unix(st.LastFullRunAt, 0)) + if sinceLast >= interval { + return Decision{Skip: false, Reason: "offline_fail_open"} + } + return Decision{ + Skip: true, + Reason: "offline_cache_skip", + NextEligibleAt: st.LastFullRunAt + int64(st.EffectiveIntervalMinutes)*60, + } +} diff --git a/internal/rungate/decide_test.go b/internal/rungate/decide_test.go new file mode 100644 index 00000000..9698225b --- /dev/null +++ b/internal/rungate/decide_test.go @@ -0,0 +1,138 @@ +package rungate + +import ( + "testing" + "time" +) + +func TestDecidePrecedence(t *testing.T) { + now := time.Unix(1_753_160_800, 0) + skipDirective := &Directive{Mode: ModeSkip, Reason: "not_due", GatingEnabled: true, EffectiveIntervalMinutes: 240, NextEligibleAt: now.Unix() + 3600} + fullDirective := &Directive{Mode: ModeFull, Reason: "interval_elapsed", GatingEnabled: true, EffectiveIntervalMinutes: 240} + + tests := []struct { + name string + in Inputs + wantSkip bool + wantReason string + }{ + { + name: "force wins over everything", + in: Inputs{ForceScan: true, LockHeldByLivePID: true, Directive: skipDirective, Now: now}, + wantSkip: false, + wantReason: "forced", + }, + { + name: "kill switch never skips", + in: Inputs{KillSwitch: true, LockHeldByLivePID: true, Directive: skipDirective, Now: now}, + wantSkip: false, + wantReason: "kill_switch", + }, + { + name: "live lock holder backs off quietly", + in: Inputs{LockHeldByLivePID: true, Directive: fullDirective, Now: now}, + wantSkip: true, + wantReason: "lock_held", + }, + { + name: "skip directive obeyed", + in: Inputs{Directive: skipDirective, Now: now}, + wantSkip: true, + wantReason: "directive:not_due", + }, + { + name: "full directive obeyed", + in: Inputs{Directive: fullDirective, Now: now}, + wantSkip: false, + wantReason: "directive:interval_elapsed", + }, + { + name: "unknown future mode degrades to full, never skip", + in: Inputs{Now: now, Directive: &Directive{Mode: "partial", Reason: "phased_rollout", GatingEnabled: true}}, + wantSkip: false, + wantReason: "directive:phased_rollout", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dec := Decide(tt.in) + if dec.Skip != tt.wantSkip || dec.Reason != tt.wantReason { + t.Fatalf("Decide() = (skip=%v, reason=%q), want (skip=%v, reason=%q)", + dec.Skip, dec.Reason, tt.wantSkip, tt.wantReason) + } + }) + } +} + +func TestDecideSkipCarriesNextEligible(t *testing.T) { + now := time.Unix(1_753_160_800, 0) + d := &Directive{Mode: ModeSkip, Reason: "not_due", GatingEnabled: true, NextEligibleAt: now.Unix() + 1234} + dec := Decide(Inputs{Directive: d, Now: now}) + if !dec.Skip || dec.NextEligibleAt != now.Unix()+1234 { + t.Fatalf("Decide() = %+v, want skip with NextEligibleAt=%d", dec, now.Unix()+1234) + } +} + +func TestDecideOfflineFallback(t *testing.T) { + now := time.Unix(1_753_160_800, 0) + nowUnix := now.Unix() + const interval = 240 // minutes + + state := func(mutate func(*State)) *State { + st := &State{ + GatingEnabled: true, + EffectiveIntervalMinutes: interval, + LastFullRunAt: nowUnix - 60, // 1 min ago + DirectiveFetchedAt: nowUnix - 3600, // 1h ago — fresh + } + if mutate != nil { + mutate(st) + } + return st + } + + tests := []struct { + name string + st *State + wantSkip bool + wantReason string + }{ + {name: "no cached state fails open", st: nil, wantSkip: false, wantReason: "offline_fail_open"}, + {name: "gating was off at last contact", st: state(func(s *State) { s.GatingEnabled = false }), wantSkip: false, wantReason: "offline_fail_open"}, + {name: "no cached interval", st: state(func(s *State) { s.EffectiveIntervalMinutes = 0 }), wantSkip: false, wantReason: "offline_fail_open"}, + {name: "never completed a run", st: state(func(s *State) { s.LastFullRunAt = 0 }), wantSkip: false, wantReason: "offline_fail_open"}, + {name: "never checked in", st: state(func(s *State) { s.DirectiveFetchedAt = 0 }), wantSkip: false, wantReason: "offline_fail_open"}, + {name: "fresh cache within interval skips", st: state(nil), wantSkip: true, wantReason: "offline_cache_skip"}, + {name: "interval elapsed locally runs", st: state(func(s *State) { s.LastFullRunAt = nowUnix - int64(interval)*60 }), wantSkip: false, wantReason: "offline_fail_open"}, + { + name: "stale cache beyond max(floor, 2x interval) runs", + st: state(func(s *State) { + s.DirectiveFetchedAt = nowUnix - int64((8*24*time.Hour)/time.Second) + }), + wantSkip: false, wantReason: "offline_fail_open", + }, + { + name: "floor keeps a days-old cache usable for short intervals", + st: state(func(s *State) { + // 2×interval = 8h, but the 7d floor governs: a 6d-old cache + // still skips when the last (e.g. forced) run was recent. + s.DirectiveFetchedAt = nowUnix - int64((6*24*time.Hour)/time.Second) + }), + wantSkip: true, wantReason: "offline_cache_skip", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dec := Decide(Inputs{State: tt.st, Now: now}) + if dec.Skip != tt.wantSkip || dec.Reason != tt.wantReason { + t.Fatalf("Decide() = (skip=%v, reason=%q), want (skip=%v, reason=%q)", + dec.Skip, dec.Reason, tt.wantSkip, tt.wantReason) + } + if tt.wantSkip && dec.NextEligibleAt != tt.st.LastFullRunAt+int64(interval)*60 { + t.Fatalf("NextEligibleAt = %d, want %d", dec.NextEligibleAt, tt.st.LastFullRunAt+int64(interval)*60) + } + }) + } +} diff --git a/internal/rungate/directive.go b/internal/rungate/directive.go new file mode 100644 index 00000000..439f07a6 --- /dev/null +++ b/internal/rungate/directive.go @@ -0,0 +1,45 @@ +// Package rungate implements the server-driven run gate: on every invocation +// the agent asks the backend's run-directive endpoint whether a full scan is +// due and exits quietly when it isn't. The scan cadence lives in the backend +// (per tenant, with temporary overrides and per-device refresh), so customers +// point their MDM/scheduler at a simple hourly launch and control the real +// frequency from the dashboard. +// +// Every failure path fails OPEN (the scan runs): a tenant that never opted +// in, an unreachable backend, a malformed response, an unresolvable device +// id, or unusable local state must never suppress scanning. The only +// deliberate skips are a backend "skip" directive, the offline cached-interval +// fallback, and the quiet back-off while another instance holds the lock. +package rungate + +// Wire contract for GET /developer-mdm-agent/run-directive. Mode and reason +// strings are wire-permanent and mirrored by the backend's +// run_directive_handler.go. +const ( + ModeFull = "full" + ModeSkip = "skip" +) + +// Directive is the backend's check-in answer. EffectiveIntervalMinutes rides +// along so the agent can cache it as its offline fallback gate; NextEligibleAt +// is informational (skip responses only). +type Directive struct { + Mode string `json:"mode"` + Reason string `json:"reason"` + GatingEnabled bool `json:"gating_enabled"` + EffectiveIntervalMinutes int `json:"effective_interval_minutes"` + NextEligibleAt int64 `json:"next_eligible_at"` + CheckedAt int64 `json:"checked_at"` +} + +// directiveEnvelope is the response wrapper, kept additive like the +// run-config envelope so future siblings don't break old agents. +type directiveEnvelope struct { + Directive Directive `json:"directive"` +} + +// ShouldSkip is the single reader of Mode. Anything that is not exactly +// ModeSkip — including future modes like "partial" — means the scan proceeds, +// so new server behavior degrades to a full scan on old agents, never to a +// silent skip. +func (d Directive) ShouldSkip() bool { return d.Mode == ModeSkip } diff --git a/internal/rungate/evaluate.go b/internal/rungate/evaluate.go new file mode 100644 index 00000000..51881d49 --- /dev/null +++ b/internal/rungate/evaluate.go @@ -0,0 +1,107 @@ +package rungate + +import ( + "context" + "fmt" + "os" + "time" + + "github.com/step-security/dev-machine-guard/internal/config" + "github.com/step-security/dev-machine-guard/internal/device" + "github.com/step-security/dev-machine-guard/internal/executor" + "github.com/step-security/dev-machine-guard/internal/lock" + "github.com/step-security/dev-machine-guard/internal/progress" +) + +// serialProbeTimeout bounds the one-off device-id probe (macOS ioreg is the +// slow case). The result is cached in the state file, so only a device's +// first gated invocation pays it. +const serialProbeTimeout = 10 * time.Second + +// Result is what main acts on: skip (exit 0 quietly) or proceed. Detail is a +// preformatted human fragment for the single skip log line. +type Result struct { + Skip bool + Reason string + Detail string +} + +// Evaluate runs the whole gate ahead of telemetry.Run: explicit escapes, a +// quiet lock peek, cached-or-probed device id, the backend check-in, the +// decision, and state persistence. It makes AT MOST one network call (none +// when an escape or the lock peek short-circuits) and NEVER fails the run — +// every error path degrades to Skip=false. +func Evaluate(ctx context.Context, exec executor.Executor, log *progress.Logger, forceScan bool) Result { + in := Inputs{ + ForceScan: forceScan || os.Getenv("STEPSEC_FORCE_SCAN") == "1", + KillSwitch: os.Getenv("STEPSEC_DISABLE_RUN_GATE") == "1", + Now: time.Now(), + } + + // Local escapes need no I/O at all; resolve them before touching disk or + // lock. Everything else defers to the backend's run-directive answer — + // there is no agent-side feature flag, so the feature is turned on or off + // entirely from the backend. + if in.ForceScan || in.KillSwitch { + if in.ForceScan { + log.Progress("Run gate: bypassed (--force-scan)") + } + return Result{Skip: false, Reason: Decide(in).Reason} + } + + // Quiet collision back-off: another instance is mid-scan. Skipping here — + // before any network call — is what keeps hourly wakeups overlapping a + // long scan from posting the lock-contention failure beacon every hour. + if pid, alive := lock.Holder(); alive { + in.LockHeldByLivePID = true + dec := Decide(in) + return Result{ + Skip: dec.Skip, + Reason: dec.Reason, + Detail: fmt.Sprintf("another instance is scanning (PID %d)", pid), + } + } + + // Device id: cached from a prior run when possible, else a bounded local + // probe. Without a real serial the backend can't be asked anything + // meaningful — fail open rather than gate on a bogus id. + st, stOK := readState() + deviceID := st.DeviceID + if deviceID == "" || deviceID == "unknown" { + probeCtx, cancel := context.WithTimeout(ctx, serialProbeTimeout) + deviceID = device.SerialNumber(probeCtx, exec) + cancel() + } + if deviceID == "" || deviceID == "unknown" { + log.Debug("run-gate: no usable device id — failing open") + return Result{Skip: false, Reason: "no_device_id"} + } + + directive, err := Checkin(ctx, config.APIEndpoint, config.APIKey, config.CustomerID, deviceID, st.LastFullRunAt) + if err != nil { + log.Debug("run-gate: check-in failed (%v) — deciding from cached state", err) + } else { + in.Directive = &directive + // Persist the resolved id + gating fields even on "full" answers so + // skipped wakeups never re-probe and the offline fallback stays + // current. Best-effort. + if perr := recordCheckin(deviceID, directive, in.Now); perr != nil { + log.Debug("run-gate: could not persist check-in state: %v", perr) + } + } + if stOK { + in.State = &st + } + + dec := Decide(in) + res := Result{Skip: dec.Skip, Reason: dec.Reason} + if dec.Skip { + detail := "cadence is managed by your StepSecurity dashboard" + if dec.NextEligibleAt > 0 { + detail = fmt.Sprintf("next scan eligible at %s", + time.Unix(dec.NextEligibleAt, 0).UTC().Format(time.RFC3339)) + } + res.Detail = detail + } + return res +} diff --git a/internal/rungate/state.go b/internal/rungate/state.go new file mode 100644 index 00000000..aa0e8d3b --- /dev/null +++ b/internal/rungate/state.go @@ -0,0 +1,193 @@ +package rungate + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "sync" + "time" + + "github.com/step-security/dev-machine-guard/internal/paths" +) + +// StateSchemaVersion is the on-disk version of run-gate-state.json. Bump only +// on a breaking shape change. +const StateSchemaVersion = 1 + +const ( + stateFileMode os.FileMode = 0o600 + stateParentDirMode os.FileMode = 0o700 +) + +// State is the gate's on-disk memory: the cached device id (so skipped +// wakeups never re-probe the serial), the last successful full run (stamped +// after the telemetry upload), and the last directive's gating fields (the +// offline fallback gate). Everything here is advisory — a missing or corrupt +// file only costs one probe and one fail-open run, never a wrong skip. +type State struct { + SchemaVersion int `json:"schema_version"` + 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 +} + +type loadStatus int + +const ( + loadOK loadStatus = iota + loadAbsentOrCorrupt + loadFutureSchema +) + +var errFutureSchema = errors.New("rungate: refusing to overwrite a newer-schema state file") + +// stateMu serializes in-process read-modify-writes (the gate at run start and +// the stamp after upload live in one sequential process today; the mutex is +// cheap insurance against future concurrent callers). Cross-process safety +// relies on atomic-rename last-writer-wins, same as the devicepolicy cache. +var stateMu sync.Mutex + +// statePathOverride lets tests redirect reads/writes to a tempdir. +var statePathOverride string + +// SetStatePathForTest redirects the state file to the given absolute path and +// returns a restore function. Test-only. +func SetStatePathForTest(p string) (restore func()) { + prev := statePathOverride + statePathOverride = p + return func() { statePathOverride = prev } +} + +func statePath() string { + if statePathOverride != "" { + return statePathOverride + } + return paths.RunGateStateFile() +} + +// loadState reads and classifies the state file. A parseable file stamped by +// a NEWER agent is loadFutureSchema: readers treat it as "no usable state" +// and writers refuse to clobber it (its fields may have changed meaning). +func loadState(path string) (State, loadStatus) { + if path == "" { + return State{}, loadAbsentOrCorrupt + } + // #nosec G304 -- path is RunGateStateFile() or a test override, never + // external input. + b, err := os.ReadFile(path) + if err != nil { + return State{}, loadAbsentOrCorrupt + } + var probe struct { + SchemaVersion int `json:"schema_version"` + } + if err := json.Unmarshal(b, &probe); err != nil { + return State{}, loadAbsentOrCorrupt + } + if probe.SchemaVersion > StateSchemaVersion { + return State{}, loadFutureSchema + } + var st State + if err := json.Unmarshal(b, &st); err != nil { + return State{}, loadAbsentOrCorrupt + } + return st, loadOK +} + +// saveState stamps the schema version and atomically replaces the file +// (temp + sync + rename, 0600 under a 0700 dir). UNLOCKED — callers hold +// stateMu. +func saveState(path string, st State) error { + if path == "" { + return errors.New("rungate: no home directory for state file") + } + st.SchemaVersion = StateSchemaVersion + data, err := json.MarshalIndent(st, "", " ") + if err != nil { + return err + } + data = append(data, '\n') + + parent := filepath.Dir(path) + if err := os.MkdirAll(parent, stateParentDirMode); err != nil { + return err + } + tmp, err := os.CreateTemp(parent, "."+filepath.Base(path)+".tmp-*") + if err != nil { + return err + } + tmpPath := tmp.Name() + defer func() { + if _, statErr := os.Stat(tmpPath); statErr == nil { + _ = os.Remove(tmpPath) + } + }() + if _, err := tmp.Write(data); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Sync(); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + if err := os.Chmod(tmpPath, stateFileMode); err != nil { + return err + } + return os.Rename(tmpPath, path) +} + +// mutateState is the shared read-modify-write: load (recreating on +// absent/corrupt, refusing on future schema), apply, save. +func mutateState(apply func(*State)) error { + stateMu.Lock() + defer stateMu.Unlock() + + path := statePath() + st, status := loadState(path) + if status == loadFutureSchema { + return errFutureSchema + } + if status == loadAbsentOrCorrupt { + st = State{} + } + apply(&st) + return saveState(path, st) +} + +// StampLastFullRun records a completed full run (called from telemetry.Run +// right after the upload succeeds). Best-effort by contract: on failure the +// next gated invocation simply runs again. +func StampLastFullRun(now time.Time) error { + return mutateState(func(st *State) { + st.LastFullRunAt = now.Unix() + }) +} + +// recordCheckin persists the freshly-resolved device id and the directive's +// gating fields after a successful check-in, preserving LastFullRunAt. The +// interval — never the skip itself — is what the offline fallback replays, so +// a stale cache can only delay a scan by one interval, not suppress it. +func recordCheckin(deviceID string, d Directive, fetchedAt time.Time) error { + return mutateState(func(st *State) { + st.DeviceID = deviceID + st.GatingEnabled = d.GatingEnabled + st.EffectiveIntervalMinutes = d.EffectiveIntervalMinutes + st.DirectiveFetchedAt = fetchedAt.Unix() + }) +} + +// readState returns the current state for the gate's decision inputs. +// ok=false covers absent, corrupt, and future-schema files alike — all mean +// "no usable local state" (fail open). +func readState() (State, bool) { + stateMu.Lock() + defer stateMu.Unlock() + st, status := loadState(statePath()) + return st, status == loadOK +} diff --git a/internal/rungate/state_test.go b/internal/rungate/state_test.go new file mode 100644 index 00000000..a022039f --- /dev/null +++ b/internal/rungate/state_test.go @@ -0,0 +1,132 @@ +package rungate + +import ( + "os" + "path/filepath" + "runtime" + "testing" + "time" +) + +func withTempState(t *testing.T) string { + t.Helper() + path := filepath.Join(t.TempDir(), "run-gate-state.json") + restore := SetStatePathForTest(path) + t.Cleanup(restore) + return path +} + +func TestStampLastFullRunCreatesAndUpdates(t *testing.T) { + path := withTempState(t) + now := time.Unix(1_753_160_800, 0) + + if err := StampLastFullRun(now); err != nil { + t.Fatalf("StampLastFullRun on absent file: %v", err) + } + st, ok := readState() + if !ok || st.LastFullRunAt != now.Unix() || st.SchemaVersion != StateSchemaVersion { + t.Fatalf("state after stamp = %+v ok=%v, want LastFullRunAt=%d schema=%d", st, ok, now.Unix(), StateSchemaVersion) + } + + if runtime.GOOS != "windows" { + info, err := os.Stat(path) + if err != nil { + t.Fatalf("stat state file: %v", err) + } + if info.Mode().Perm() != 0o600 { + t.Fatalf("state file mode = %v, want 0600", info.Mode().Perm()) + } + } + + later := now.Add(4 * time.Hour) + if err := StampLastFullRun(later); err != nil { + t.Fatalf("StampLastFullRun update: %v", err) + } + st, _ = readState() + if st.LastFullRunAt != later.Unix() { + t.Fatalf("LastFullRunAt = %d, want %d", st.LastFullRunAt, later.Unix()) + } +} + +func TestStampAndRecordPreserveEachOther(t *testing.T) { + withTempState(t) + now := time.Unix(1_753_160_800, 0) + + if err := StampLastFullRun(now); err != nil { + t.Fatalf("stamp: %v", err) + } + d := Directive{Mode: ModeSkip, Reason: "not_due", GatingEnabled: true, EffectiveIntervalMinutes: 240} + if err := recordCheckin("SER123", d, now.Add(time.Minute)); err != nil { + t.Fatalf("recordCheckin: %v", err) + } + + st, ok := readState() + if !ok { + t.Fatal("state unreadable after both writes") + } + if st.LastFullRunAt != now.Unix() { + t.Errorf("recordCheckin clobbered LastFullRunAt: %d", st.LastFullRunAt) + } + if st.DeviceID != "SER123" || !st.GatingEnabled || st.EffectiveIntervalMinutes != 240 { + t.Errorf("check-in fields not persisted: %+v", st) + } + + if err := StampLastFullRun(now.Add(2 * time.Hour)); err != nil { + t.Fatalf("second stamp: %v", err) + } + st, _ = readState() + if st.DeviceID != "SER123" || st.EffectiveIntervalMinutes != 240 || st.DirectiveFetchedAt != now.Add(time.Minute).Unix() { + t.Errorf("stamp clobbered check-in fields: %+v", st) + } +} + +func TestFutureSchemaRefusal(t *testing.T) { + path := withTempState(t) + future := `{"schema_version": 99, "device_id": "FROM-THE-FUTURE", "last_full_run_at": 42}` + "\n" + if err := os.WriteFile(path, []byte(future), 0o600); err != nil { + t.Fatalf("seed future file: %v", err) + } + + if _, ok := readState(); ok { + t.Fatal("readState must treat a future-schema file as unusable") + } + if err := StampLastFullRun(time.Unix(1_753_160_800, 0)); err == nil { + t.Fatal("StampLastFullRun must refuse to overwrite a future-schema file") + } + b, err := os.ReadFile(path) + if err != nil { + t.Fatalf("re-read: %v", err) + } + if string(b) != future { + t.Fatalf("future-schema file was modified:\n%s", b) + } +} + +func TestCorruptFileIsRecreated(t *testing.T) { + path := withTempState(t) + if err := os.WriteFile(path, []byte("not json{{"), 0o600); err != nil { + t.Fatalf("seed corrupt file: %v", err) + } + if _, ok := readState(); ok { + t.Fatal("corrupt file must read as unusable") + } + now := time.Unix(1_753_160_800, 0) + if err := StampLastFullRun(now); err != nil { + t.Fatalf("stamp over corrupt file: %v", err) + } + st, ok := readState() + if !ok || st.LastFullRunAt != now.Unix() { + t.Fatalf("state not recreated cleanly: %+v ok=%v", st, ok) + } +} + +func TestNoPathFailsSoft(t *testing.T) { + // paths.RunGateStateFile() returns "" when Home() is disabled; both + // primitives must degrade softly so the gate's callers fail open. + if _, status := loadState(""); status != loadAbsentOrCorrupt { + t.Fatalf("loadState(\"\") status = %v, want loadAbsentOrCorrupt", status) + } + if err := saveState("", State{}); err == nil { + t.Fatal("saveState(\"\") must error (best-effort caller logs it)") + } +} diff --git a/internal/telemetry/telemetry.go b/internal/telemetry/telemetry.go index 7c72c593..edff3bbc 100644 --- a/internal/telemetry/telemetry.go +++ b/internal/telemetry/telemetry.go @@ -30,6 +30,7 @@ import ( "github.com/step-security/dev-machine-guard/internal/model" "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/rungate" "github.com/step-security/dev-machine-guard/internal/schedinfo" "github.com/step-security/dev-machine-guard/internal/state" "github.com/step-security/dev-machine-guard/internal/tcc" @@ -1156,6 +1157,11 @@ func Run(exec executor.Executor, log *progress.Logger, cfg *cli.Config) (err err log.Debug("scan-state: saved %s (telemetry-out mode)", scanStatePath) } } + // Same rationale for the run-gate stamp: the dev harness should show + // the same second-invocation gating behavior as a real upload. + if err := rungate.StampLastFullRun(time.Now()); err != nil { + log.Debug("run-gate: could not stamp last full run: %v", err) + } return nil } @@ -1189,6 +1195,12 @@ func Run(exec executor.Executor, log *progress.Logger, cfg *cli.Config) (err err } } + // Record the completed full run for the run gate. Best-effort by + // contract: a missing stamp only means the next gated invocation runs. + if err := rungate.StampLastFullRun(time.Now()); err != nil { + log.Debug("run-gate: could not stamp last full run: %v", err) + } + fmt.Fprintln(os.Stderr) log.Progress("Telemetry collection completed successfully") tccSkipper.LogHits(log.Debug)