Skip to content
Draft
Show file tree
Hide file tree
Changes from 8 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
127 changes: 123 additions & 4 deletions cmd/stepsecurity-dev-machine-guard/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -262,8 +262,13 @@ func main() {
os.Exit(1)
}
armExecutionWatchdog(telemetry.ExecutionDeadline(config.MaxExecutionDuration), log)
if err := telemetry.Run(exec, log, cfg); err != nil {
log.Error("%v", err)
telemetryErr := telemetry.Run(exec, log, cfg)
// Package-config enforcement runs on every cycle, even one where telemetry
// failed, so an emergency unassignment/offboarding directive is never
// blocked by a telemetry outage — hence before the error-exit below.
runPackageConfigEnforce(exec, log)
if telemetryErr != nil {
log.Error("%v", telemetryErr)
os.Exit(1)
}
runHookStateReconcile(exec, log)
Expand Down Expand Up @@ -351,6 +356,16 @@ func main() {
}
}

// Package-config enforcement runs even if the initial telemetry failed
// (before the error-exit below). Skipped on Windows at install time: an
// elevated installer resolves the ADMINISTRATOR's home, not the developer's,
// so let the first scheduled /ru INTERACTIVE firing do the first enforcement
// (macOS root installs resolve the console user; Linux installs are user
// mode, and the Windows-SYSTEM / macOS paths already returned above).
if runtime.GOOS != model.PlatformWindows {
runPackageConfigEnforce(exec, log)
}

if telemetryErr != nil {
if cfg.IgnoreTelemetryError {
// Opt-in tolerance for MSI/SCCM/Intune deployments: the
Expand Down Expand Up @@ -475,8 +490,12 @@ func main() {
case config.IsEnterpriseMode():
log.Debug("dispatch: enterprise telemetry (auto-detected)")
armExecutionWatchdog(telemetry.ExecutionDeadline(config.MaxExecutionDuration), log)
if err := telemetry.Run(exec, log, cfg); err != nil {
log.Error("%v", err)
telemetryErr := telemetry.Run(exec, log, cfg)
// Package-config enforcement runs on every enterprise cycle — including a
// manually invoked one, and even when telemetry failed.
runPackageConfigEnforce(exec, log)
if telemetryErr != nil {
log.Error("%v", telemetryErr)
os.Exit(1)
}
default:
Expand Down Expand Up @@ -746,3 +765,103 @@ func runIDEExtensionEnforce(exec executor.Executor, log *progress.Logger) {
aiagentscli.AppendError("devicepolicy", "enforce_failed", err.Error(), "")
}
}

// runPackageConfigEnforce fetches the device's effective package-config policy
// (the npm secure-registry directive) and converges the managed block in the
// console user's ~/.npmrc to match, then reports compliance — on the same
// scheduled cycle and agent auth channel as the IDE-extension enforcement above.
// It runs on every telemetry cycle, INCLUDING cycles where telemetry itself
// failed, so an emergency unassignment/offboarding directive is never blocked by
// a telemetry outage. A device whose npm config is already governed by the MDM
// remediation script is detected by the writer's content-aware probe and reported
// mdm_managed instead. A silent no-op when enterprise config is missing. Failures
// are logged but never crash main.
func runPackageConfigEnforce(exec executor.Executor, log *progress.Logger) {
cfg, ok := ingest.Snapshot()
if !ok {
log.Debug("package-config enforce: skipped (no enterprise config)")
return
}
fetcher, ok := devicepolicy.NewHTTPFetcher(cfg, nil)
if !ok {
log.Debug("package-config enforce: skipped (fetcher init refused config)")
return
}
reporter, ok := devicepolicy.NewHTTPReporter(cfg, nil)
if !ok {
log.Debug("package-config enforce: skipped (reporter init refused config)")
return
}

ctx, cancel := context.WithTimeout(context.Background(), devicePolicyEnforceTimeout)
defer cancel()

dev := device.Gather(ctx, exec)
if dev.SerialNumber == "" || dev.SerialNumber == "unknown" {
log.Warn("package-config enforce: device serial unresolved; skipping")
return
}
serial := dev.SerialNumber

r := &devicepolicy.Reconciler{
Fetcher: fetcher,
Reporter: reporter,
CustomerID: cfg.CustomerID,
DeviceID: serial,
Platform: dev.Platform,
Category: devicepolicy.CategoryPackageConfig,
Target: devicepolicy.TargetNPM,
// Render derives the two managed ~/.npmrc content lines from the policy and
// this device's serial. It fully validates the policy and is pure, so it is
// wired even when the writer below could not be constructed.
Render: func(policy json.RawMessage) (string, error) {
return devicepolicy.RenderNPMRCBlock(policy, serial)
},
OwnsByMarker: true,
// The managed block is one atomic unit, so the lane owns exactly one
// WrittenSettings entry under this key.
OwnershipKey: devicepolicy.NPMOwnedKey,
Logf: func(format string, args ...any) { log.Debug(format, args...) },
}

// The writer resolves the console user and opens a directory fd over their
// home. When it cannot (no enforceable target user, or an infrastructure
// failure) leave the writer seams nil and hand the reconciler the init error:
// it classifies AFTER the fetch (absent → silent, clear → retain all state,
// enforce → policy_not_applied for no-target else write_failed). Binding
// w.Converged / w.ProbeExpected before this nil check would capture method
// values on a nil receiver, and the deferred Close would panic.
w, werr := devicepolicy.NewNPMRCWriter(exec)
if werr != nil {
r.WriterInitErr = werr
} else {
defer w.Close()
w.SetLogf(func(format string, args ...any) { log.Debug(format, args...) })

// Concurrent convergence of this ~/.npmrc is not serialized across
// processes. Every write is an atomic temp+rename, so an overlapping
// cycle never sees a torn file. While the policy is stable both cycles
// render identical bytes; only a policy transition (a key rotation, or an
// enforce racing a clear) that interleaves with a concurrent cycle can
// briefly leave the superseded value, reconverged next cycle — eventual
// consistency, the same model the VS Code settings.json lane relies on.
// The telemetry singleton lock already serializes the preceding scan phase.
// Ownership state is the exception: it shares one file with every other
// category, so its read-modify-write does take a cross-process lock.
r.Writer = w
r.Converged = w.Converged
r.ProbeExpected = w.ProbeExpected
r.RestoreSnapshot = w.RestoreSnapshot
// Verify-only channel (enforcement=mdm): read the effective ~/.npmrc and
// report the observed bag instead of writing. Bound here because it needs the
// writer's identity-checked read path; with no writer the reconciler's
// category-aware fallback reports verification_failed rather than probing VS
// Code policy for an npm category.
r.ProbeContent = w.ProbeContentNPM
}

if err := r.Reconcile(ctx); err != nil {
log.Warn("package-config enforce: %v", err)
aiagentscli.AppendError("devicepolicy", "enforce_failed", err.Error(), "")
}
}
119 changes: 79 additions & 40 deletions internal/devicepolicy/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,46 +39,48 @@ const maxRunConfigBytes = 4 << 20

// EffectivePolicy is the parsed device-policy directive, lifted from the
// `policy` sub-object of the run-config response (it mirrors agent-api's
// EffectivePolicyResponse). Policy is the settings map: VS Code setting id
// (e.g. "extensions.allowed", "extensions.gallery.serviceUrl") → that setting's
// compiled value as canonical JSON — the exact bytes the backend hashed. The
// agent writes each value verbatim and never re-serializes
// (re-serialization could reorder keys and break the backend's byte-exact
// applied==desired check). extensions.allowed is always present; other keys
// appear only when the policy sets them. Policy identity is (Category, Target);
// Target defaults to vscode for ide_extension. Enforcement selects the channel:
// "mdm" is verify-only (probe and report, no settings write), "dmg" or "" is the
// write-and-verify path. A zero EffectivePolicy (present()==false) means
// run-config carried no directive for this category/target → reconciler no-op.
// EffectivePolicyResponse). Policy carries the compiled policy object as
// canonical JSON — the exact bytes the backend hashed — kept RAW because its
// shape is per-category: for ide_extension it is the settings map (VS Code
// setting id, e.g. "extensions.allowed" / "extensions.gallery.serviceUrl", → that
// setting's compiled value), for package_config it is the npm registry object.
// Each category decodes what it needs; the agent writes the compiled values
// verbatim and never re-serializes (re-serialization could reorder keys and break
// the backend's byte-exact applied==desired check). Policy identity is
// (Category, Target); Target defaults to vscode for ide_extension. Enforcement
// selects the channel: "mdm" is verify-only (probe and report, never a write),
// "dmg" or "" is the write-and-verify path. A zero EffectivePolicy
// (present()==false) means run-config carried no directive for this
// category/target → reconciler no-op.
type EffectivePolicy struct {
Category string
Target string
Clear bool
Policy map[string]json.RawMessage
Policy json.RawMessage
Hash string
GeneratedAt string
Enforcement string
}

// present reports whether the backend expressed a policy directive for this
// category — a value to enforce, or an explicit clear. The fetcher guarantees
// clear=false ⇒ a non-empty settings map, so the only successful-fetch state
// clear=false ⇒ a non-empty policy object, so the only successful-fetch state
// with neither is "no policy in run-config" (absent), which the reconciler
// treats as a no-op (NEVER a clear).
func (ep EffectivePolicy) present() bool { return ep.Clear || len(ep.Policy) > 0 }

// policyEnvelope is the wire shape of the run-config `policy` sub-object (must
// match agent-api EffectivePolicyResponse). `policy` is the settings map
// (setting id → compiled value). Unknown fields are ignored, so a backend
// emitting extra fields the agent does not model stays compatible.
// match agent-api EffectivePolicyResponse). `policy` stays raw for the same
// per-category reason as EffectivePolicy.Policy. Unknown fields are ignored, so a
// backend emitting extra fields the agent does not model stays compatible.
type policyEnvelope struct {
Category string `json:"category"`
Target string `json:"target"`
Clear bool `json:"clear"`
Policy map[string]json.RawMessage `json:"policy,omitempty"`
Hash string `json:"hash,omitempty"`
GeneratedAt string `json:"generated_at"`
Enforcement string `json:"enforcement,omitempty"` // "dmg" | "mdm" | ""
Category string `json:"category"`
Target string `json:"target"`
Clear bool `json:"clear"`
Policy json.RawMessage `json:"policy,omitempty"`
Hash string `json:"hash,omitempty"`
GeneratedAt string `json:"generated_at"`
Enforcement string `json:"enforcement,omitempty"` // "dmg" | "mdm" | ""
}

// Fetcher returns the effective policy for one device + category + target.
Expand Down Expand Up @@ -123,9 +125,11 @@ func NewHTTPFetcher(cfg ingest.Config, h *http.Client) (*HTTPFetcher, bool) {
// - body that is not valid JSON → error;
// - a non-clear result missing policy or hash → error (a malformed policy
// must not be written, and must not be mistaken for a clear);
// - a non-clear result whose `policy` is not a JSON object → error: it must
// decode as a setting id → value map, so a string/array/scalar in its place
// fails the decode above and no-ops the reconciler.
// - a non-clear policy that is not itself a JSON object → error (a string or
// array written verbatim could even read back "compliant");
// - a non-clear ide_extension policy whose settings map does not carry an
// extensions.allowed object → error (validateCategoryPolicy). The check is
// category-gated: a package_config policy legitimately has no such key.
//
// An omitted/null `policy` is NOT an error: it means run-config carried no
// directive for this category/target (a degraded/rules-only response, an older
Expand Down Expand Up @@ -206,36 +210,71 @@ func (c *HTTPFetcher) Fetch(ctx context.Context, customerID, deviceID, category,
GeneratedAt: p.GeneratedAt,
Enforcement: strings.TrimSpace(p.Enforcement),
}
// Reject a response scoped to a different category/target than requested
// (backend bug, proxy/cache mixup). Acting on it could enforce — or worse,
// clear — the wrong pair. An empty field is not a mismatch; it defaults to
// the requested value just below.
if ep.Category != "" && ep.Category != category {
return EffectivePolicy{}, fmt.Errorf("devicepolicy: response category %q does not match requested %q", ep.Category, category)
}
if ep.Target != "" && ep.Target != target {
return EffectivePolicy{}, fmt.Errorf("devicepolicy: response target %q does not match requested %q", ep.Target, target)
}
if ep.Category == "" {
ep.Category = category
}
if ep.Target == "" {
ep.Target = target
}
if !ep.Clear {
// A non-clear directive must carry a settings map and a hash. The map's
// object shape is already guaranteed: a non-object `policy` fails to
// decode into the map above and returns a decode error, so a
// string/array/scalar never reaches here.
// A non-clear directive must carry a policy object and a hash.
if len(ep.Policy) == 0 || ep.Hash == "" {
return EffectivePolicy{}, errors.New("devicepolicy: malformed policy: clear=false but policy or hash missing")
}
// extensions.allowed is the mandatory core of every ide_extension policy:
// it MUST be present and a JSON object. This rejects an allowlist-missing
// settings map (e.g. a gallery-only response) before it can be written and
// read back "compliant". Other keys stay backend-owned — their values are
// heterogeneous (the allowlist an object, the gallery URL a string).
allow, ok := ep.Policy[allowedExtensionsSettingKey]
if !ok {
return EffectivePolicy{}, errors.New("devicepolicy: malformed policy: settings missing " + allowedExtensionsSettingKey)
// The compiled policy is always a JSON object, in EVERY category. Shape is
// checked here so a malformed payload no-ops at the reconciler; a
// string/array/scalar written verbatim could even read back "compliant".
if !isJSONObject(ep.Policy) {
return EffectivePolicy{}, errors.New("devicepolicy: malformed policy: policy is not a JSON object")
}
if !isJSONObject(allow) {
return EffectivePolicy{}, errors.New("devicepolicy: malformed policy: " + allowedExtensionsSettingKey + " is not a JSON object")
if err := validateCategoryPolicy(ep.Category, ep.Policy); err != nil {
return EffectivePolicy{}, err
}
}
return ep, nil
}

// validateCategoryPolicy applies the per-category structural checks that top-level
// object shape does not cover. It is category-GATED on purpose: the raw policy is
// shaped differently per category, so an ide_extension key requirement must never
// be imposed on a package_config object.
//
// - ide_extension: extensions.allowed is the mandatory core of the policy — it
// MUST be present and a JSON object. This rejects an allowlist-missing settings
// map (e.g. a gallery-only response) before it can be written and read back
// "compliant". Other keys stay backend-owned: their values are heterogeneous
// (the allowlist an object, the gallery URL a string).
// - package_config: object shape is sufficient here. The npm structure
// (ecosystem / registry_url / auth) is validated downstream by
// RenderNPMRCBlock, which rejects a malformed policy before any write.
func validateCategoryPolicy(category string, policy json.RawMessage) error {
if category != CategoryIDEExtension {
return nil
}
var settings map[string]json.RawMessage
if err := json.Unmarshal(policy, &settings); err != nil {
return fmt.Errorf("devicepolicy: malformed policy: settings map: %w", err)
}
allow, ok := settings[allowedExtensionsSettingKey]
if !ok {
return errors.New("devicepolicy: malformed policy: settings missing " + allowedExtensionsSettingKey)
}
if !isJSONObject(allow) {
return errors.New("devicepolicy: malformed policy: " + allowedExtensionsSettingKey + " is not a JSON object")
}
return nil
}

// isJSONObject reports whether raw's first JSON token opens an object. Callers
// pass syntactically valid JSON (decoded by json.Unmarshal, or json.Valid-checked
// first), so only the shape needs checking.
Expand Down
Loading
Loading