Skip to content

feat(devicepolicy): enforce VS Code private-marketplace URL + add MDM verify-only channel#178

Merged
ashishkurmi merged 5 commits into
step-security:mainfrom
raysubham:feat/vsc-private-markeplace-support
Jul 24, 2026
Merged

feat(devicepolicy): enforce VS Code private-marketplace URL + add MDM verify-only channel#178
ashishkurmi merged 5 commits into
step-security:mainfrom
raysubham:feat/vsc-private-markeplace-support

Conversation

@raysubham

@raysubham raysubham commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Summary

Extends the ide_extension#vscode reconciler to cover the full VS Code enterprise-policy surface — the extension allowlist (extensions.allowed) and the private-marketplace URL (extensions.gallery.serviceUrl) — delivered through two enforcement channels:

  • DMG (write-and-verify) — the default (enforcement: "dmg" or empty). The agent writes both keys into user-scope settings.json in one atomic merge, tracks per-key ownership, and reports compliance.
  • MDM (verify-only) — when an external MDM owns the VS Code policy (enforcement: "mdm"), the agent never writes; it probes the OS-managed policy, reports what it observed, and lets the backend decide drift.

Additive and regression-guarded: an allowlist-only DMG policy compiles, writes, and records ownership exactly as before (byte-identical settings.json and cache record).

Part 1 — Private-marketplace URL enforcement (DMG)

Folds extensions.gallery.serviceUrl into the reconciler beside the allowlist, and generalizes the model from a single key to a settings map (setting id → compiled value; the hash covers the whole map):

  • api: run-config policy is a settings map; extensions.allowed is required and must be a JSON object. (Breaking run-config wire change, pre-GA: requires agent-api to emit the settings-map shape.)
  • settings_writer: managedSettingsWriter seam — ReadManaged / ApplyManaged / RestoreManaged over a set of keyed ops (one atomic load→patch→store). Single-key Write/Read/Clear retained for the npm writer.
  • reconcile: enforce and clear are fully driven by the settings map + recorded ownership — no per-key special-casing, so a new managed setting rides through with no code change. Ownership-gated set/remove/preserve; convergence over the full key set before the idempotency short-circuit; drift over owned keys; atomic multi-key rollback on a post-write persist failure.
  • cache: ownership tracked per key in WrittenSettings (map, omitempty → an allowlist-only record is unchanged on disk); WrittenValue now used only by the single-key npm writer.

Semantics: URL present → set authoritatively (overwrite whatever is there); URL absent → remove only the value the agent wrote (a user-configured URL is preserved); clear:true → remove each owned key independently; VS Code adopts a changed gallery at next restart while the agent converges settings.json immediately (file state is the contract).

Part 2 — MDM verify-only channel

enforcement selects the channel per cycle. "mdm" is verify-only — the agent probes the OS-managed VS Code policy and reports what it observed, and never writes, patches, or clears settings.json (an external MDM owns it). It routes before the writer/clear gates, so it runs even on a platform with no settings path.

  • probe (ProbeManagedContent, per-OS) reads AllowedExtensions and ExtensionGalleryServiceUrl into the observed bag keyed by VS Code setting id:
    • windows: registry, gating on the exact types VS Code honors — REG_SZ / REG_MULTI_SZ only. A REG_EXPAND_SZ / DWORD value is dropped by vscode-policy-watcher (confirmed against its source: StringPolicy supportedTypes = {REG_SZ, REG_MULTI_SZ}, wrong type → nullopt), so it reads as unmanaged in DMG mode (agent enforces via write) and verification_failed in MDM mode. The presence probe gates on the same set, so a wrong-typed value no longer suppresses enforcement.
    • darwin: managed-preferences plist (binary or XML via howett.net/plist), machine-wide overlaid by the console user's per-user plist. The user is resolved via $HOME to match the settings writer (a root LaunchDaemon bakes $HOME→console user but leaves $USER=root).
    • linux: /etc/vscode/policy.json; a malformed or null value is verification_failed, never a silent absence.
  • reconcile / report: compliance reports carry observed + evaluated_enforcement (the normalized, canonical channel) so the backend can diff like-for-like. Channel routing is case/space-insensitive; an unknown channel fails safe to the DMG write path.
  • dependency: adds howett.net/plist v1.0.1 — pure-Go plist parsing for the darwin content probe (CGO stays off).

Testing

  • go test ./internal/devicepolicy/ -race — DMG writer goldens + reconcile transitions (unchanged), plus the MDM path: verify-only states (clear/absent no-op, probe error → verification_failed, not-present → policy_not_applied, present → mdm_managed + observed), verify runs with a nil writer, case-insensitive routing with canonical echo, and per-OS content probes (darwin native; linux + windows fixtures, incl. Windows REG_EXPAND_SZ/DWORD rejection and unsupported-HKLM→valid-HKCU fallthrough).
  • Gates: gofmt -l clean · go vet (darwin/linux/windows) · golangci-lint 0 issues (×3 GOOS) · cross-compile CGO_ENABLED=0 linux/amd64 + darwin/arm64 + windows/amd64 (and internal/devicepolicy freebsd) · go mod tidy no-diff. Windows-tagged probe tests execute in CI (Test (windows devicepolicy)).

No version bump or CHANGELOG entry (handled at release cut).

Fold an optional extensions.gallery.serviceUrl into the existing
ide_extension#vscode reconciler, written into the user-scope
settings.json beside extensions.allowed via one atomic multi-key write.
The agent owns both keys: set is authoritative, removal is
ownership-gated (never deletes a user-configured value), and drift,
convergence, selective clear, and post-write rollback all cover both.

- api: lift gallery_service_url from run-config into EffectivePolicy
- settings_writer: managedSettingsWriter (ReadManaged / ApplyManaged /
  RestoreManaged) over a set of keyed ops; single-key Write/Read/Clear kept
- reconcile: ownership-gated set/remove/preserve for the gallery key;
  convergence over both keys before the idempotency short-circuit; drift
  over owned keys; atomic multi-key rollback; per-key ownership-safe clear
- cache: additive WrittenSettings map (omitempty; an allowlist-only
  record is unchanged on disk)
- probe: yield mdm_managed when an MDM owns AllowedExtensions OR
  ExtensionGalleryServiceUrl (Windows / macOS / Linux)

Additive and regression-guarded: an allowlist-only policy writes exactly
as before (byte-identical settings.json and ownership record).
…tings

Move the VS Code allowlist's ownership record from the scalar WrittenValue
into the WrittenSettings map alongside the gallery URL, so every managed key
is tracked uniformly by setting id. WrittenValue is now used only by the
single-key (npm) writer; adds omitempty so managed records no longer carry a
dead written_value field. No behavior change: ownership is read back from the
same values the agent writes.
… map)

The run-config policy payload is now a settings map (setting id → compiled value) instead of the bare extensions.allowed object plus a sibling gallery_service_url field; the hash covers the whole map.

enforce and clear are fully driven by the settings map and recorded ownership — no per-key special-casing — so a new managed setting rides through with no code change. Fetch now requires extensions.allowed to be present and a JSON object. Deleted managedKeys/managedGalleryValue; added compactSettings/sortedUnion/sortedKeys.

Breaking run-config wire change (pre-GA): requires agent-api to emit the settings-map shape.
…xtension policy

Enforcement selects a channel per cycle: "dmg" (or "") is the write-and-verify
path; "mdm" is verify-only — the agent probes the OS-managed VS Code policy and
reports what it observed, and never writes, patches, or clears settings.json
(an external MDM owns the policy).

- ProbeManagedContent (per-OS) reads AllowedExtensions and
  ExtensionGalleryServiceUrl into the observed bag keyed by VS Code setting id:
  - windows: registry, gating on the types VS Code honors (REG_SZ / REG_MULTI_SZ;
    REG_EXPAND_SZ and others are ignored, matching vscode-policy-watcher).
  - darwin: managed-preferences plist, machine-wide overlaid by the console
    user's per-user plist (user resolved via $HOME to match the settings writer).
  - linux: /etc/vscode/policy.json; malformed/null values are verification_failed,
    never a silent absence.
- Compliance reports carry observed + evaluated_enforcement (the normalized,
  canonical channel) so the backend can diff like-for-like.
- Channel routing is case/space-insensitive; unknown channels fail safe to the
  DMG write path.
- The presence probe (DMG-mode yield) also gates on REG_SZ/REG_MULTI_SZ, so a
  wrong-typed policy value that VS Code ignores no longer suppresses enforcement.
@raysubham raysubham changed the title feat(devicepolicy): enforce VS Code private marketplace URL feat(devicepolicy): enforce VS Code private-marketplace URL + add MDM verify-only channel Jul 23, 2026
@ashishkurmi
ashishkurmi requested a review from Copilot July 24, 2026 10:42

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Extends the internal/devicepolicy VS Code IDE extension reconciler to manage the full VS Code enterprise policy surface (extension allowlist + private marketplace URL) and introduces a second enforcement channel: DMG (write-and-verify) vs MDM (verify-only) with per-OS policy probing.

Changes:

  • Generalizes VS Code policy enforcement from a single managed key to a multi-key settings map (atomic merge/patch/write + per-key ownership).
  • Adds verify-only "mdm" enforcement routing, including per-OS managed-policy content probes and reporting of observed values.
  • Updates run-config parsing and compliance reporting wire shapes to carry the settings map + observed evidence + evaluated enforcement.

Reviewed changes

Copilot reviewed 19 out of 20 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
internal/devicepolicy/settings_writer.go Adds multi-key managed settings API (read/apply/restore) and gallery URL setting key support.
internal/devicepolicy/settings_writer_test.go Adds regression tests for multi-key writer behavior (bytes preserved, presence semantics, restore, invalid inputs).
internal/devicepolicy/reconcile.go Adds enforcement routing (DMG vs MDM), multi-key enforcement/clear logic, and report stamping.
internal/devicepolicy/reconcile_test.go Expands reconcile tests for multi-key ownership/drift/rollback and MDM verify-only reporting behavior.
internal/devicepolicy/probe.go Adds gallery policy name, shared policy-name detection list, and shared observed-bag building/parsing helpers.
internal/devicepolicy/probe_windows.go Updates Windows managed-policy presence probe (type-gated) and adds registry content probing for MDM verify-only.
internal/devicepolicy/probe_windows_test.go Adds Windows tests for type gating, per-key hive fallback, and verify-only content probe behavior.
internal/devicepolicy/probe_test.go Adds cross-platform tests for “either policy name” detection and buildObserved translation/error handling.
internal/devicepolicy/probe_other.go Adds no-op stub for ProbeManagedContent on unsupported OSes.
internal/devicepolicy/probe_linux.go Extends Linux presence probe to either key and adds policy.json content parsing for verify-only.
internal/devicepolicy/probe_linux_test.go Adds Linux-only tests for presence detection and content parsing/error semantics.
internal/devicepolicy/probe_darwin.go Extends macOS presence probe to either key and adds managed-preferences plist parsing for verify-only.
internal/devicepolicy/probe_darwin_test.go Adds macOS-only tests for presence detection and plist content probing/precedence/user resolution.
internal/devicepolicy/cache.go Extends cached applied state to include per-key ownership (written_settings) alongside legacy written_value.
internal/devicepolicy/cache_test.go Adds tests for written_settings round-trip and omission behavior.
internal/devicepolicy/api.go Changes effective policy policy to a settings map, adds enforcement, and adds observed/enforcement fields to compliance report.
internal/devicepolicy/api_test.go Updates fetch/report tests for new settings-map shape, allowlist requirements, gallery URL lift, and enforcement field handling.
go.mod Adds howett.net/plist dependency for macOS plist parsing in the MDM probe.
go.sum Updates checksums for the new plist dependency and transitive entries.
cmd/stepsecurity-dev-machine-guard/main.go Ensures verify-only enforcement can still run/report even when no settings path exists (nil writer).

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +395 to +418
switch {
case op.Set:
// The patch document embeds the value verbatim; reject anything that
// is not valid JSON before it can corrupt the patch (defense in depth
// — the caller passes already-compacted, validated values). Object
// shape is NOT required: a managed value may be a JSON string (the
// gallery URL) as well as an object (the allowlist).
if !json.Valid(op.Value) {
return nil, fmt.Errorf("devicepolicy: refusing to write invalid JSON value for %q to %s", op.Key, w.path)
}
// op.Key is a dotted setting id with no '/' or '~', so it needs no
// JSON-Pointer escaping; the dot is literal.
patchOps = append(patchOps, `{"op":"add","path":"/`+op.Key+`","value":`+string(op.Value)+`}`)
case op.Remove:
// RFC 6902 "remove" errors on an absent member; pre-check presence so
// a Remove of a key that is not there is simply skipped.
_, present, perr := extractKey(v, op.Key)
if perr != nil {
return nil, perr
}
if present {
patchOps = append(patchOps, `{"op":"remove","path":"/`+op.Key+`"}`)
}
}
Comment thread internal/devicepolicy/reconcile.go Outdated
Comment on lines +154 to +167
// Normalize once for routing AND reporting: the backend gates on an exact
// "mdm"/"dmg", so EvaluatedEnforcement must carry the canonical value, not
// whatever casing/spacing arrived.
r.enforcement = strings.ToLower(strings.TrimSpace(ep.Enforcement))

// MDM is verify-only and owns nothing on disk, so it routes before the
// Writer/clear checks below.
switch r.enforcement {
case enforcementMDM:
return r.verifyMDM(ctx, cat, tgt, ep)
case enforcementDMG, "":
default:
r.logf("devicepolicy: unknown enforcement %q; running DMG path", ep.Enforcement)
}
Comment on lines +667 to +678
func ownedKeys(prev AppliedTargetState, hadPrev bool) map[string]string {
owned := map[string]string{}
if !hadPrev {
return owned
}
for k, v := range prev.WrittenSettings {
if v != "" {
owned[k] = v
}
}
return owned
}
…nical enforcement channel

Address PR review comments:

- ApplyManaged built the RFC-6902 patch path by raw string concat with the
  backend-supplied key. RFC 6901-escape the pointer token (~->~0, /->~1) and
  JSON-encode the path (jsonPointerPath) so an unusual key cannot forge a nested
  pointer path or corrupt the patch document. The value is still spliced
  verbatim (never re-encoded, which would reorder members or HTML-escape it).

- EvaluatedEnforcement reported the raw request, so an unknown channel ran the
  DMG path yet reported the raw string. Resolve and stamp the canonical channel
  the cycle actually ran -- always "dmg" or "mdm" (empty/unknown -> "dmg") -- so
  the backend's exact-match gate sees the channel that executed.
@ashishkurmi
ashishkurmi merged commit 2cf07e2 into step-security:main Jul 24, 2026
12 checks passed
raysubham added a commit to raysubham/dev-machine-guard that referenced this pull request Jul 25, 2026
Brings in the merged IDE-extension policy work (step-security#178) that the npm lane's reconciler, probe, and api layers build on. reconcile.go conflicted because both lanes changed the enforce ladder; resolved by keeping both paths on the shared seams - marker-based clearing for the ~/.npmrc block writer, the multi-key managed path for VS Code settings.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants