feat(devicepolicy): enforce npm secure-registry policy via managed ~/.npmrc block#174
Draft
raysubham wants to merge 7 commits into
Draft
feat(devicepolicy): enforce npm secure-registry policy via managed ~/.npmrc block#174raysubham wants to merge 7 commits into
raysubham wants to merge 7 commits into
Conversation
….npmrc block
Add the package_config#npm device-policy lane: converge a StepSecurity-owned
block in the console user's ~/.npmrc so npm (and the pnpm / yarn v1 / bun tools
that read the same file) resolve packages through the tenant's secure registry,
alongside the existing VS Code ide_extension lane.
The target is a file the agent may touch as root (macOS LaunchDaemon) against a
user-controlled home, so every file operation goes through Go 1.26 os.Root with
explicit symlink-chain resolution, post-open identity re-checks, and metadata
changes on open handles (never by path) to defeat symlink/entry-swap races.
Supporting pieces:
- an INI classifier that mirrors npm's own key/value parsing (spaced forms,
inline comments, single/double-quoted and JSON-escaped keys) and fails
closed on INI sections, bare CRs, and coercible non-string quoted keys it
cannot safely reason about;
- a cross-process reconciliation lock (flock / LockFileEx) serializing
scheduled and manual cycles per guarded file;
- a per-uid ownership state store kept out of the shared unlocked cache;
- reconciler seams (Converged / Render / ProbeExpected / RestoreSnapshot /
OwnsByMarker / State / ...) that leave the settings.json path byte-identical
when unset;
- a content-aware MDM probe, transactional write/clear with snapshot rollback,
and bounded, identity-checked backups.
Gated behind the existing device-policy feature gate.
… convergence Remove the bespoke cross-process reconciliation lock (npmrc_lock.go, AcquireReconcileLock via flock/LockFileEx) from the package_config#npm enforce path. The process-wide singleton lock (internal/lock, acquired inside telemetry.Run) already serializes the scan phase across every invocation and privilege mode; only the enforce step runs outside it, and that overlap is atomic-write-safe: each ~/.npmrc write is a temp+rename of a deterministically rendered block, so concurrent cycles never observe a torn file. While a 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 on the next cycle (eventual consistency). This matches the VS Code ide_extension lane, which enforces the same way with no lock. - delete npmrc_lock.go and the flock / LockFileEx helpers in the platform files; - set the reconciler seams directly in runPackageConfigEnforce; - correct the state-store comments that credited the removed lock; - drop the four lock lifecycle / contention tests. Also clear the residual gosec findings in npmrc.go the way the file already handles cleanup errors: _ = on error-path Close() calls, and a #nosec G115 justification on the POSIX uid conversion.
TestFileStateStoreRoundTrip asserted the state file's mode is exactly 0600, but Windows has no POSIX permission bits: os.Chmod only toggles the write bit and Stat reports 0666 for any writable file, so the check could never hold on Windows (the store's own logic is unaffected — the state file round-trips fine). Guard the assertion with runtime.GOOS != "windows", matching the existing settings_writer_test.go idiom; the rest of the round-trip still runs on every platform.
The package_config#npm enforcement lane is ready to ship, so remove its dedicated feature gate and run it unconditionally. The gate was default-on already, so runtime behavior is unchanged; the lane stays dormant in production until the backend returns a package_config policy (an absent policy is a no-op, never a wipe). The IDE-extension lane keeps its own FeatureDevicePolicy gate. Removes the FeaturePackageConfigPolicy const, its enabled-map entry, and the gate check in runPackageConfigEnforce.
There was a problem hiding this comment.
Pull request overview
Adds a new device-policy enforcement lane for package_config/npm that converges a StepSecurity-managed secure-registry block in the console user’s ~/.npmrc, reusing the existing devicepolicy reconciler via new seams and a separate per-user/per-mode applied-state store.
Changes:
- Introduces an
~/.npmrcmanaged-block writer (cross-platform + race-resistant I/O) and wires it into enterprise telemetry cycles. - Extends
devicepolicy.Reconcilerwith optional seams (rendering, convergence/probing, rollback, marker-based ownership, state store injection) to support non-settings.jsontargets without changing IDE behavior. - Adds a dedicated
StateStoreimplementation forpackage_configand hardens the policy fetcher against category/target mismatches.
Reviewed changes
Copilot reviewed 13 out of 13 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| internal/devicepolicy/verify.go | Adds package_config / npm identifiers for policy fetch/report scoping. |
| internal/devicepolicy/statestore.go | Implements a per-target state-store abstraction and a file-backed store for package-config applied state. |
| internal/devicepolicy/statestore_test.go | Unit tests for state-store placement, schema handling, corruption behavior, and mode expectations. |
| internal/devicepolicy/reconcile.go | Adds reconciler seams (render/probe/converged/rollback/state store) to support npmrc lane while preserving IDE defaults. |
| internal/devicepolicy/reconcile_npm_test.go | Exercises reconciler behavior with npm-specific seams and marker-based ownership semantics. |
| internal/devicepolicy/npmrc.go | Adds NPMRCWriter: secure managed-block convergence for ~/.npmrc, including rendering/validation, probing, backups, and rollback. |
| internal/devicepolicy/npmrc_unix.go | Unix-specific metadata enforcement (mode/owner), nonblocking open flag, and owner reader implementation. |
| internal/devicepolicy/npmrc_windows.go | Windows-specific session/identity guard and no-op metadata enforcement/owner checks. |
| internal/devicepolicy/npmrc_unix_test.go | On-disk Unix tests for writer behavior, symlink handling, metadata enforcement, and rollback semantics. |
| internal/devicepolicy/npmrc_test.go | Pure tests for rendering, INI classifier behavior, rewrite/clear transforms, and probe logic. |
| internal/devicepolicy/api.go | Rejects run-config responses whose category/target don’t match the request to prevent mis-scoped enforcement/clear. |
| internal/devicepolicy/api_identity_test.go | Tests the new category/target identity checks in the fetch path. |
| cmd/stepsecurity-dev-machine-guard/main.go | Wires package-config enforcement into enterprise telemetry cycles (including when telemetry fails) and install-time behavior. |
Comments suppressed due to low confidence (1)
internal/devicepolicy/npmrc.go:1243
- Function name has a likely typo ("unquoteININToken"), which makes the intent harder to read/search for. Consider renaming to "unquoteINIToken" to match the INI terminology used throughout this file.
// unquoteININToken mirrors npm ini unsafe()'s quoted branch, which JSON-parses a
// quoted token rather than merely stripping the quotes. A double-quoted token is
// JSON-decoded whole, so string escapes resolve — `"registry"` decodes to
// `registry`, an active override we must recognize — and falls back to the
// ORIGINAL quoted string when it is not a valid JSON string (npm keeps the quoted
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Comment on lines
+270
to
+273
| if err := os.Chmod(tmpPath, cacheFileMode); err != nil { | ||
| return err | ||
| } | ||
| return os.Rename(tmpPath, s.path) |
Comment on lines
+1206
to
+1210
| func npmUnsafe(s string) string { | ||
| s = strings.TrimSpace(s) | ||
| if inner, ok := unquoteININToken(s); ok { | ||
| return inner | ||
| } |
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.
…pm lane
Enforcement already selects a channel per cycle for IDE-extension policy. This
extends the same model to package_config#npm: "mdm" is verify-only — the agent
reads the effective ~/.npmrc and reports what it observed, and never writes,
patches, or clears the file (an external MDM owns the block).
- ProbeContentNPM is the verify-only reader, bound in main.go because it needs
the writer's identity-checked read path. Presence is an MDM marker outside
every agent-owned block, so a marker planted inside one of ours cannot pass as
MDM management. It reports the observed bag {ecosystem, registry_url,
auth_token_status} computed over the whole file's last-wins precedence, so an
override below the MDM block surfaces as drift instead of being hidden. No
token, hash, or fingerprint is ever returned or logged. With no writer the
reconciler's category-aware fallback reports verification_failed rather than
probing VS Code policy for an npm category.
- EffectivePolicy.Policy is kept RAW (json.RawMessage) instead of a settings
map: the compiled shape is per-category — a settings map for ide_extension, a
registry object for package_config — and each category decodes what it needs.
Values are still written verbatim, never re-serialized, so the backend's
byte-exact applied==desired check holds.
- Ownership collapses onto WrittenSettings for every lane; the single-value
WrittenValue field is retired. A single-value path records exactly one entry
under its own OwnershipKey (the ~/.npmrc block, or the allowlist setting id for
a degraded VS Code writer). A state file carrying the retired key decodes as
"owns nothing" and the next enforce re-converges it.
- ProbeManagedContent takes the expected value so a content probe can decide
presence against what the policy actually compiled to.
Hardening on the npm lane:
- The verify-only reader transmits an observed plaintext http:// registry as
drift evidence instead of refusing it — discarding it would hide the most
security-relevant drift the channel exists to catch. The observed value is
bounded instead: an absolute http(s) URL with a host, 2048 bytes, and no
userinfo, query, fragment, or control bytes.
- Fail closed on npm's `key[]=` array-append form on a managed key. npm's ini
reader folds it into the same key as the block's scalar assignment, so both
orders leave npm resolving to a comma-joined list containing the injected
value while a last-wins scalar scan still reported converged. Scoped to
`registry` and the tenant token key, so unrelated array config (`omit[]=dev`,
another registry's token) is still enforceable.
- Fail closed when clearing a file whose only line breaks are bare CRs. The
managed block could not be located, so the clear reported the nothing-to-do
success and ownership state was dropped while the token stayed on disk.
- Purge our own backups once a clear leaves nothing managed. Removing the block
from the live file while leaving a readable sibling copy of it revokes nothing.
Best-effort with a retry on later no-op clears: a sibling we cannot unlink must
not report a revocation that already happened as failed.
- Log a loose file mode in the verify-only reader rather than failing it. Perms
are outside the observed contract and that lane cannot remediate by writing, so
a hard failure would be permanent and would hide the real registry and auth
status. Ownership is still enforced on every read.
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.
Summary
Adds the
package_config#npmdevice-policy enforcement lane: the agent converges a StepSecurity-owned managed block in the console user's~/.npmrcso npm (and the pnpm / yarn v1 / bun tools that read the same file) resolve packages through the tenant's secure registry. It runs alongside the existing VS Codeide_extensionlane and reuses the same reconciler, driven through nil-able seams so the settings.json path stays byte-identical.What's included
~/.npmrcmanaged-block writer (npmrc.go,npmrc_unix.go,npmrc_windows.go) — operates on a user-owned tree the agent may touch as root (macOS LaunchDaemon). Every file op goes through Go 1.26os.Rootwith explicit symlink-chain resolution, post-open identity re-checks (Lstat + SameFile), and metadata changes on open handles only — never by path — to defeat symlink / directory-entry-swap races. Transactional write/clear with snapshot rollback, and bounded, identity-checked, 0600 token-bearing backups.[section]headers, bare\rline breaks, and single-quoted non-string JSON keys npm would coerce into an override.telemetry.Runreleases the process-wide singleton lock, so two cycles can overlap only in the write step. Each write is an atomic temp+rename of a deterministically rendered block: identical bytes while the policy is stable, and only a self-healing stale-value window if a policy transition (key rotation / enforce-vs-clear) interleaves with a concurrent cycle, reconverged next cycle. Same model as the lock-free VS Code lane.statestore.go) kept out of the shared device-policy cache so an IDE reconcile in another process can't drop the npm record — separate files make that cross-process lost update structurally impossible without a lock.reconcile.go:Converged/Render/ProbeExpected/RestoreSnapshot/OwnsByMarker/State/WriterInitErr) — every seam at its zero value reproduces the settings.json behavior byte-for-byte.~/.npmrcis user-writable, a bare marker isn't proof; the probe verifies the MDM lane's block is present, effective (last-wins), and correctly owned.Security model
A user who can plant symlinks or swap directory entries mid-operation must not be able to steer a root-owned write, a root read, or a token-bearing backup outside their own regular
~/.npmrc. Token material (<api_key>::dev:<serial>) is never logged, and offboarding removes every managed block — including duplicates — so no live token survives a clear.Testing
go test ./...— all packages pass, including-race.go vet+gofmtclean; builds green on darwin / linux / windows.Notes
package_configpolicy (which ships after this agent release), so the reconciler no-ops until then — an absent or transient policy is always a no-op, never a wipe. The IDE lane keeps its ownFeatureDevicePolicygate.npmrc_lock.go, flock / LockFileEx); it was removed (commit62d9e78) once it was clear the telemetry singleton lock already serializes the scan phase and the enforce overlap is atomic-write-safe. Accepted residual: a narrow, self-healing stale-value window when a policy transition overlaps a concurrent cycle — bounded to one lost cycle, never a corrupt file. Removing the lock also cleared its gosec findings; the remainingnpmrc.gocleanup-error and uid-conversion findings are handled in-convention (_ =/#nosec G115).Opening as a draft for review.