Skip to content

Dependency policies#1168

Merged
ericmj merged 12 commits into
mainfrom
dependency-policies
Jun 17, 2026
Merged

Dependency policies#1168
ericmj merged 12 commits into
mainfrom
dependency-policies

Conversation

@ericmj

@ericmj ericmj commented May 22, 2026

Copy link
Copy Markdown
Member

Adds client-side enforcement of dependency policies. A policy is defined on hex.pm under an organization (or on any self-hosted repo) and fetched through the registry at resolution time. The client filters candidate versions the policy blocks before the solver sees them, so resolution simply never picks them.

Per repository, a policy can express:

  • a cooldown (minimum age before a newly published version becomes eligible)
  • an advisory rule (block versions with security advisories at or above a severity threshold, or with any advisory)
  • a retirement rule (block versions retired for the configured reasons)
  • package/version overrides (allow or deny exceptions; the most specific match wins, and an allow exempts the release from the restriction entirely)

Versions already in the lockfile are exempt from filtering, so re-resolution keeps a locked-but-now-blocked entry instead of failing. Policies have a visibility: public policies are fetchable anonymously, private ones require authentication to the owning organization.

Configuring the policy

A project has exactly one active policy, resolved with the usual Hex config precedence: env var, then mix.exs, then global config.

HEX_POLICY env var, a REPO/NAME pair. Overrides everything for the invocation, and an empty value disables the configured policy:

$ HEX_POLICY=hexpm:myorg/strict-prod mix deps.get
$ HEX_POLICY= mix deps.get

mix.exs :hex block, with org: for a hexpm organization or repo: for any configured repo:

defp project() do
  [hex: [policy: [org: "myorg", name: "strict-prod"]]]
end

mix hex.config, stored in the global Hex config:

$ mix hex.config policy hexpm:myorg/strict-prod

The bare hexpm repo is rejected in all forms: the global repository has no organization-scoped policies, so policies live under hexpm:<org> or a self-hosted repo.

New mix tasks

mix hex.policy show summarizes the active policy: its visibility, the per-repository restrictions (cooldown, advisory rule, retirement rule) and override counts, plus the effective cooldown, which is the strictest of the local cooldown config and the policy's own.

mix hex.policy why PACKAGE (or REPO/PACKAGE) walks every version of the package in the registry, classifies each against the active policy, and prints a per-version table:

Versions of "plug" (47):

Version  Status   Blocked by
1.16.0   ALLOWED
1.17.0   BLOCKED  retirement: security
1.18.0   BLOCKED  advisory ≥ high, cooldown 14d; eligible 2026-06-20

This is the uncapped companion to the resolution output below. mix hex.config policy reads and writes the setting like any other config key.

How policies surface in mix deps.get

After a successful resolution, a summary block reports the active policy, the cooldown it imposes (a locally configured cooldown keeps its own existing report), and every candidate version it hid, capped at the 5 newest per package:

Active policy: hexpm:myorg/strict-prod
Effective cooldown: 14d (hexpm:myorg/strict-prod)
Policy hid 7 candidate versions:
  phoenix 1.8.1 — cooldown 14d; eligible 2026-06-18
  plug 1.18.0 — advisory ≥ high
  ...and 5 more — run `mix hex.policy why plug`

When resolution fails and the policy hid versions of the packages involved, the solver's failure message is followed by a note attributing the hidden versions, in the same capped format, so a "no compatible versions" error explains itself:

Note: active policy hides 3 versions of "plug":
  plug 1.18.0 — advisory ≥ high
  ...

Because a policy is an enforcement feature, anything short of materializing it fails closed rather than resolving unenforced:

  • a malformed config value aborts with Invalid policy configuration: ... and the expected formats
  • a failed fetch prints Failed to fetch policy REPO/NAME from registry and aborts, unless a previously fetched copy is cached, in which case the cache is used and the message says so
  • on a 404 or 401 the error explains the policy may not exist, may be misspelled, or may be private without sufficient permissions, and points at mix hex.user auth when no authenticated user is present

@ericmj ericmj force-pushed the dependency-policies branch from 6e7e434 to 9c58eb5 Compare June 2, 2026 00:05
@ericmj ericmj marked this pull request as ready for review June 2, 2026 19:52
ericmj added 2 commits June 8, 2026 18:42
Pull in the restructured Policy resource: a Policy now carries a repeated
RepositoryPolicy ("tab") per repository it constrains, each with an optional
baseline Restriction (advisory_min_severity, retirement_reasons, cooldown) and
a list of allow/deny Overrides. Restriction imports package.proto and types
advisory_min_severity and retirement_reasons as the AdvisorySeverity and
RetirementReason enums, so decoded values are symbolic.
Introduce organization-defined dependency policies that filter the versions
available during resolution. A policy is fetched through the repository like
the registry (etag/304, last-known-good fallback, HEX_OFFLINE).

A policy configures one or more repositories ("tabs", typically the org's own
repo and "hexpm"); a repository with no tab is unconstrained by the policy.
Each tab has an optional baseline restriction (advisory severity, retirement
reason, release-age cooldown) applied to every release in the repository, plus
allow/deny overrides as the per-package lever — an allow override permits a
release and exempts it from the restriction, a deny override blocks it, and the
most specific matching override wins. Versions already in the lockfile are
exempt from filtering.

- Hex.Policy and Hex.Policy.{Sources,Filter,Diagnostics}: config plumbing via
  Hex.State, AND-composition of policy sources, per-repository classification
  (overrides then restriction), and resolution diagnostics.
- Hex.Registry.Policy / Hex.Registry.Server: policies are prefetched and cached
  alongside the registry; locked versions bypass policy filtering.
- Cooldown: the local cooldown drives the global cutoff; policy cooldowns are
  per-repository restrictions enforced in Hex.Policy.Filter, so AND composition
  yields the strictest minimum age per repository. The effective cooldown is
  surfaced in diagnostics and mix hex.policy.
- mix hex.policy inspects active policies.
@ericmj ericmj marked this pull request as draft June 9, 2026 10:48
@ericmj ericmj force-pushed the dependency-policies branch from b55fc8f to ef15376 Compare June 9, 2026 11:00
ericmj added 5 commits June 10, 2026 17:12
A project opts into exactly one policy, so the three separate config
sources (project/env/global) collapse into one ordinary `policy` config
key with the usual override precedence (HEX_POLICY, then mix.exs, then
hex.config), matching every other Hex setting.

This removes the multi-source union/dedup/ordering machinery: the
`:policies` map becomes a single `:active_policy`, `Hex.Policy.Sources`
is deleted (its parsing folds into `Hex.Policy`), and the filter and
registry stop handling lists of policies.
Fail closed instead of open on enforcement gaps:

- A malformed policy config (HEX_POLICY, mix.exs, or hex.config) now
  aborts resolution instead of degrading to unenforced behind a warning.
  Hex.State keeps the invalid value tagged and Hex.Policy.load/0 raises.
- A configured policy that fails to materialize from the registry raises
  instead of being silently dropped.
- Unknown advisory severities (future enum values or missing fields) rank
  above every known severity so they can never slip under a threshold.
- Policy fetches for an org repo whose custom URL does not follow the
  /repos/<org> layout return an error instead of deriving a wrong URL.

Registry server:

- close/persist now wait for in-flight policy fetches; previously a
  queued close could delete the ETS table mid-fetch and crash the server.
- Update the fun2ms comment for the policy matchspec entries and simplify
  prefetch_policies_offline.

Diagnostics and UX:

- Print the resolution summary's effective cooldown line only when the
  policy is the source; the local cooldown already has its own
  filtered-versions report.
- Singular forms for "1 candidate version" / "1 version of".
- mix hex.policy raises the conventional short invalid-arguments message
  instead of dumping the moduledoc.
- Cooldown.strictest/1 normalizes unparseable durations to 0d.
- Align the cooldown source format between the resolution summary and
  mix hex.policy show, expose print_policy_summary for testing, and
  update stale multi-policy docs in mix hex.config.
- Record a blocked version only once even when the solver asks for a
  package's versions repeatedly, so summary counts are accurate
- An empty HEX_POLICY disables the configured policy for the invocation
- Reject bare `mix hex.policy`; the show subcommand must be explicit
- Accept [org: "ORG", name: "NAME"] in the mix.exs policy config,
  expanding to the hexpm:<org> repo, and correct the REPO/NAME
  terminology in the invalid-config error and task docs
- List hidden versions per package in the policy summary and failure
  note, newest-first and capped at 5 with a pointer to
  `mix hex.policy why`; apply the same shape to the cooldown report
Hex.Policy.parse_config now validates and normalizes to the canonical
REPO/NAME string instead of a parsed tuple, with the ref split moved to
the point of use in Hex.Policy.load. This lets the policy key flow
through mix hex.config's generic read/list/set/delete paths like every
other config key, removing all special-casing and a bug where the
listing printed the policy twice.
@ericmj ericmj marked this pull request as ready for review June 12, 2026 14:45
Comment thread lib/hex/policy/filter.ex
Comment thread lib/hex/policy/filter.ex
@@ -0,0 +1,210 @@
defmodule Hex.Policy.Filter do

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Should this be a helper module in hex_core?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Yes, we can move there it in the future.

Comment thread lib/hex/policy/filter.ex
Comment thread lib/hex/policy/filter.ex
Comment thread lib/hex/policy/filter.ex
Comment thread lib/hex/policy/filter.ex
Comment thread lib/hex/policy/filter.ex
Comment thread lib/hex/policy/filter.ex
Comment thread lib/hex/policy.ex
Comment thread lib/hex/repo.ex Outdated
Comment thread lib/hex/policy/filter.ex
ericmj and others added 3 commits June 16, 2026 07:54
Co-authored-by: Jonatan Männchen <jonatan@maennchen.ch>
Co-authored-by: Eric Meadows-Jönsson <eric.meadows.jonsson@gmail.com>
The applied review suggestions did not compile (invalid `:hex_pb_policy.Policy()`
typespecs and a `ref:%{` spacing error) and, once made to parse, replaced
`Map.get(map, :key, default)` with direct field access and `%{key: nil}`
patterns. hex_core decodes protobuf with maps_unset_optional => omitted, so
unset optional fields (restriction, overrides, repositories) are absent from
the decoded map rather than nil, and the direct access crashed. Restore the
defensive Map.get-based version.
Hex.Repo.get_policy peeled the /repos/<org> suffix off the repo URL so
hex_core could re-insert it via a separate repo_organization config
field. Re-vendor hex_core, where get_policy now builds its URL from
repo_url like every other resource and verifies the payload against
repo_name, and drop the now-unneeded put_repo_organization translation.
A custom repo URL no longer has to end in /repos/<org> to carry a policy.
@ericmj ericmj force-pushed the dependency-policies branch from a963b79 to 2033548 Compare June 16, 2026 16:10
@ericmj ericmj requested a review from maennchen June 16, 2026 17:04
Document that rejecting the bare hexpm repo is deliberate and would be
relaxed if a global policy is ever served directly under hexpm.
maennchen added a commit to hexpm/specifications that referenced this pull request Jun 17, 2026
Reflect Hex (Elixir/Mix) client support for consuming security
advisories and enforcing organization-defined dependency policies
(cooldown, advisory severity, retirement, overrides) at resolution
time. Rebar3 and Gleam do not yet consume advisories or enforce
policies.

See hexpm/hexpm#1622 and hexpm/hex#1168.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Resolve the vendored hex_core conflicts by re-vendoring from hex_core
main (d6a6a5a), which already carries both the policy proto and the new
hex_auth/hex_cli_auth modules. The textual merge of lib/hex/repo.ex left
the organization config key inconsistent: main standardized on
:repo_organization (base URL + build_url inserting /repos/<org>), so
drop the policy branch's leftover :organization key and update the
policy registry test to main's convention.
@ericmj ericmj merged commit f431e47 into main Jun 17, 2026
22 checks passed
@ericmj ericmj deleted the dependency-policies branch June 17, 2026 09:36
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.

2 participants