Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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: 4 additions & 2 deletions cmd/stepsecurity-dev-machine-guard/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -701,8 +701,10 @@ func runIDEExtensionEnforce(exec executor.Executor, log *progress.Logger) {
}
writer, ok := devicepolicy.NewWriter()
if !ok {
log.Debug("ide-extension enforce: skipped (no settings path on this platform)")
return
// No user-scope settings path (no home / %APPDATA%). The write path
// no-ops on a nil Writer, but verify-only (MDM) mode owns nothing on
// disk and must still probe and report — so continue, don't return.
log.Debug("ide-extension enforce: no settings path; verify-only still runs if assigned")
}
cfg, ok := ingest.Snapshot()
if !ok {
Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ require (
github.com/tidwall/pretty v1.2.1
github.com/tidwall/sjson v1.2.5
gopkg.in/yaml.v3 v3.0.1
howett.net/plist v1.0.1
)

require github.com/tidwall/match v1.1.1 // indirect
4 changes: 4 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaU
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc=
github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/tailscale/hujson v0.0.0-20260302212456-ecc657c15afd h1:Rf9uhF1+VJ7ZHqxrG8pJ6YacmHvVCmByDmGbAWCc/gA=
Expand All @@ -22,5 +23,8 @@ golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
howett.net/plist v1.0.1 h1:37GdZ8tP09Q35o9ych3ehygcsL+HqKSwzctveSlarvM=
howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g=
94 changes: 59 additions & 35 deletions internal/devicepolicy/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,41 +39,46 @@ 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 carries the compiled VS Code
// extensions.allowed object as canonical JSON (sorted keys) — the exact bytes
// the backend hashed — so the agent writes it verbatim and never re-serializes
// 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). Policy identity is (Category, Target); Target
// defaults to vscode for ide_extension. A zero EffectivePolicy (present()==false)
// means run-config carried no directive for this category/target → reconciler
// no-op.
// 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.
type EffectivePolicy struct {
Category string
Target string
Clear bool
Policy json.RawMessage
Policy map[string]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 ⇒ non-empty policy object, so the only successful-fetch state
// clear=false ⇒ a non-empty settings map, 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). Unknown fields are ignored, so a
// backend still emitting legacy extras (e.g. the removed min_vscode_version)
// stays compatible.
// 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.
type policyEnvelope struct {
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"`
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" | ""
}

// Fetcher returns the effective policy for one device + category + target.
Expand Down Expand Up @@ -118,8 +123,9 @@ 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 policy that is not itself a JSON object → error (a string or
// array written verbatim could even read back "compliant").
// - 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.
//
// 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 @@ -198,6 +204,7 @@ func (c *HTTPFetcher) Fetch(ctx context.Context, customerID, deviceID, category,
Policy: p.Policy,
Hash: strings.TrimSpace(p.Hash),
GeneratedAt: p.GeneratedAt,
Enforcement: strings.TrimSpace(p.Enforcement),
}
if ep.Category == "" {
ep.Category = category
Expand All @@ -206,22 +213,32 @@ func (c *HTTPFetcher) Fetch(ctx context.Context, customerID, deviceID, category,
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.
if len(ep.Policy) == 0 || ep.Hash == "" {
return EffectivePolicy{}, errors.New("devicepolicy: malformed policy: clear=false but policy or hash missing")
}
// The compiled policy is always a JSON object. Shape is checked here so a
// malformed payload no-ops at the reconciler; value-level validation stays
// backend-owned.
if !isJSONObject(ep.Policy) {
return EffectivePolicy{}, errors.New("devicepolicy: malformed policy: policy is not a JSON object")
// 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)
}
if !isJSONObject(allow) {
return EffectivePolicy{}, errors.New("devicepolicy: malformed policy: " + allowedExtensionsSettingKey + " is not a JSON object")
}
}
return ep, nil
}

// isJSONObject reports whether raw's first JSON token opens an object. The
// envelope already passed json.Unmarshal, so raw is syntactically valid JSON —
// only the shape needs checking.
// 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.
func isJSONObject(raw json.RawMessage) bool {
for _, b := range raw {
switch b {
Expand All @@ -237,14 +254,21 @@ func isJSONObject(raw json.RawMessage) bool {
// computed on-device. It is the agent-side mirror of agent-api's
// complianceReport. AppliedHash is the backend's hash echoed verbatim — never
// recomputed locally — so the backend's byte-exact applied==desired check
// (which gates the `compliant` verdict) can succeed.
// (which gates the `compliant` verdict) can succeed. In verify-only (mdm) mode
// AppliedHash is empty and Observed instead carries the OS-managed policy the
// agent read, keyed by VS Code setting id (the same keys as the desired policy)
// so the backend can diff like-for-like and decide drift — the agent does not
// judge match. EvaluatedEnforcement echoes the channel the cycle ran ("", "dmg"
// or "mdm"). Observed and EvaluatedEnforcement are omitted when empty.
type ComplianceReport struct {
Category string `json:"category"`
Target string `json:"target"`
State string `json:"state"`
AppliedHash string `json:"applied_hash"`
AgentVersion string `json:"agent_version"`
Platform string `json:"platform"`
Category string `json:"category"`
Target string `json:"target"`
State string `json:"state"`
AppliedHash string `json:"applied_hash"`
AgentVersion string `json:"agent_version"`
Platform string `json:"platform"`
Observed json.RawMessage `json:"observed,omitempty"`
EvaluatedEnforcement string `json:"evaluated_enforcement,omitempty"`
}

// Reporter submits a compliance report for one device.
Expand Down
Loading
Loading