feat(developer-mdm): server-driven scan cadence with per-invocation run gating - #179
Open
shubham-stepsecurity wants to merge 2 commits into
Open
feat(developer-mdm): server-driven scan cadence with per-invocation run gating#179shubham-stepsecurity wants to merge 2 commits into
shubham-stepsecurity wants to merge 2 commits into
Conversation
shubham-stepsecurity
force-pushed
the
sm/run-gating
branch
from
July 31, 2026 05:54
7ba28f3 to
3076853
Compare
There was a problem hiding this comment.
Pull request overview
Adds an enterprise “run gate” mechanism that checks in with the backend for a server-driven scan cadence, allowing the agent to exit 0 early when a run isn’t due while maintaining a fail-open contract and an offline cached-interval fallback. This fits into the enterprise telemetry path by gating send-telemetry runs before expensive collection/upload and persisting minimal state alongside the existing heartbeat file.
Changes:
- Introduces
internal/rungate(check-in client, decision logic, state cache) and integrates gating into enterprise execution paths inmain.go. - Extends
last-run.json(heartbeat) to also store run-gate cache, and stamps “last full run” after successful telemetry completion. - Adds the
--force-scanbypass flag and documents the new behavior in README/CHANGELOG.
Reviewed changes
Copilot reviewed 17 out of 17 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| README.md | Documents --force-scan and related env vars for enterprise run gating. |
| CHANGELOG.md | Adds an Unreleased entry describing server-driven scan cadence (needs wording alignment with implementation). |
| cmd/stepsecurity-dev-machine-guard/main.go | Calls the run gate before enterprise telemetry runs (including inline install scan). |
| internal/cli/cli.go | Adds ForceScan config + --force-scan parsing (help/tests need follow-up). |
| internal/device/device.go | Adds a lightweight SerialNumber accessor for pre-scan device-id resolution. |
| internal/heartbeat/heartbeat.go | Extends last-run.json to preserve run-gate cache via read-modify-write. |
| internal/heartbeat/heartbeat_test.go | Tests the new heartbeat + run-gate merge behavior. |
| internal/paths/paths.go | Documents last-run.json as shared heartbeat + run-gate state storage. |
| internal/telemetry/telemetry.go | Echoes gate proceed reason into captured logs and stamps last-full-run on completion. |
| internal/rungate/client.go | Implements backend check-in + best-effort skip beacon. |
| internal/rungate/client_test.go | Tests check-in request/response handling and error paths. |
| internal/rungate/decide.go | Implements precedence and offline cached-interval fallback decision logic. |
| internal/rungate/decide_test.go | Tests decision precedence and offline fallback behavior. |
| internal/rungate/directive.go | Defines the directive wire shape (docs need to match the actual endpoint used). |
| internal/rungate/evaluate.go | Orchestrates the gating flow (env access + logging level choices need adjustment). |
| internal/rungate/state.go | Adapts run-gate persistence to the shared heartbeat file. |
| internal/rungate/state_test.go | Tests shared-file invariants and corruption/future-schema behavior. |
Suppressed comments (2)
internal/rungate/evaluate.go:74
- Run-gate intermediate logging uses Progress level, which contradicts the stated goal of a single visible skip/proceed line (main.go comment + changelog) and will add extra log noise on every scheduler wakeup. Consider downgrading the “checking cadence”, directive details, and check-in-failure lines to Debug so only gateSkipsRun emits the single user-facing Progress line.
log.Progress("Run gate: checking scan cadence with the dashboard...")
directive, err := Checkin(ctx, config.APIEndpoint, config.APIKey, config.CustomerID, deviceID, st.LastFullRunAt)
if err != nil {
log.Progress("Run gate: dashboard check-in failed, using cached cadence: %v", err)
} else {
internal/cli/cli.go:302
- --force-scan is added to parsing but is not included in the CLI help text (printHelp Options list), so users running --help won’t discover it unless they read the README. Add it to the help output alongside other top-level flags.
case arg == "--force-scan":
cfg.ForceScan = true
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Comment on lines
+37
to
+42
| 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(), | ||
| } |
Comment on lines
+48
to
+53
| if in.ForceScan || in.KillSwitch { | ||
| if in.ForceScan { | ||
| log.Progress("Run gate: bypassed (--force-scan)") | ||
| } | ||
| return Result{Skip: false, Reason: Decide(in).Reason} | ||
| } |
Comment on lines
+1
to
+17
| // 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. |
|
|
||
| ### 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`. |
Comment on lines
299
to
+302
| case arg == "--override-gate": | ||
| cfg.OverrideGate = true | ||
| case arg == "--force-scan": | ||
| cfg.ForceScan = true |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What does this PR do?
Type of change
Testing
./stepsecurity-dev-machine-guard --verbose./stepsecurity-dev-machine-guard --json | python3 -m json.toolmake lintmake testRelated Issues