Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,11 @@ your platform team can set, and what each one does to your install:
- **`dependencies.require`** -- packages your `apm.yml` must include.
- **`dependencies.max_depth`** -- maximum transitive dependency depth.
- **`dependencies.require_pinned_constraint`** -- when `true`, every
APM dep declared in your `apm.yml` must use a bounded constraint
(semver range, literal tag, or 40-char SHA); bare branch names,
wildcards, and open-upper ranges (`>=1.0.0`) are rejected.
direct APM dep declared in your `apm.yml` must use a bounded constraint
(semver range, literal tag such as `v1.2.3` or `pkg-a-v1.2.3`, or
40-char SHA); bare branch names, wildcards, and open-upper ranges
(`>=1.0.0`) are rejected. The constraint is judged as you declared it,
not as the tag it resolved to.
- **`mcp.allow`** / **`mcp.deny`** -- glob patterns over MCP server
references. Same semantics as the dependency lists.
- **`mcp.transport.allow`** -- restricts MCP transports
Expand Down
5 changes: 4 additions & 1 deletion docs/src/content/docs/consumer/manage-dependencies.md
Original file line number Diff line number Diff line change
Expand Up @@ -454,7 +454,10 @@ lockfile alongside the resolved tag, so `apm install` on a fresh clone
replays the same tag deterministically. A malformed range-like ref is
rejected; use a plain range such as `^1.2.0` or pin a literal tag such as
`pkg-a-v1.2.0`. Only `apm update` (or legacy `apm install --update`) or a
manifest change re-resolves to a newer tag.
manifest change re-resolves to a newer tag. Under an org policy with
`require_pinned_constraint: true`, both forms count as bounded: the range
is judged as the range you wrote, and `pkg-a-v1.2.0` is recognised as
this package's own release tag.

Marketplace object-form ranges use the marketplace package name in the tag
pattern:
Expand Down
8 changes: 5 additions & 3 deletions docs/src/content/docs/enterprise/policy-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,14 +186,16 @@ dependencies:

### `require_pinned_constraint`

Default: `false`. When `true`, every APM dependency declared in `apm.yml` must use a bounded constraint -- a semver range with an upper bound, a literal version tag (e.g. `v1.5.3`), or a 40-char commit SHA. Empty refs, bare branch names, wildcards (`*`, `1.x`), and open-upper ranges (`>=1.0.0`, `>1.0.0`) all fail the `dependency-pinned-constraint` check.
Default: `false`. When `true`, every direct APM dependency declared in `apm.yml` must use a bounded constraint -- a semver range with an upper bound, a literal version tag (`v1.5.3`, or `pkg-a-v1.5.3` / `pkg-a--v1.5.3` for a package named `pkg-a`, the layouts APM resolves ranges against), or a 40-char commit SHA. Empty refs, bare branch names, wildcards (`*`, `1.x`), and open-upper ranges (`>=1.0.0`, `>1.0.0`) all fail the `dependency-pinned-constraint` check.

The check judges the constraint as declared. A range such as `pkg-a#^1.0.0` passes as a bounded choice even though the install selects a concrete release tag for it, and a bare branch stays a violation however it resolves. Fresh installs, locked reinstalls, updates, and `apm audit` agree.

```yaml
dependencies:
require_pinned_constraint: true
```

Transitive deps are also classified; they pass when their parent manifests pinned them. See the [policy schema reference](../../reference/policy-schema/#require_pinned_constraint-reference) for the full classification table and diagnostic format.
Only direct dependencies are evaluated; a transitive package's own manifest is not something the consumer can rewrite. See the [policy schema reference](../../reference/policy-schema/#require_pinned_constraint-reference) for the full classification table and diagnostic format.

---

Expand Down Expand Up @@ -430,7 +432,7 @@ There are 21 policy checks.
| `required-executable-untrusted` | Required executable packages are present and trusted |
| `required-package-version` | Required packages with version pins match per `require_resolution` |
| `transitive-depth` | No dependency exceeds `max_depth` |
| `dependency-pinned-constraint` | Every dep uses a bounded constraint (semver range, literal tag, or SHA) when `require_pinned_constraint: true` |
| `dependency-pinned-constraint` | Every direct dep declares a bounded constraint (semver range, literal tag, or SHA) when `require_pinned_constraint: true` |
| `registry-source` | Dependencies come from required registries when `registry_source` is configured |

**Integrity:**
Expand Down
6 changes: 5 additions & 1 deletion docs/src/content/docs/reference/policy-schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ When a dependency matches `deny`, `apm install` will **not download or deploy th
| `require` | list of refs or null | `null` | `null` = no opinion (transparent during merge). `[]` = explicitly empty. Packages every consumer manifest must include. |
| `require_resolution` | enum | `project-wins` | `project-wins` / `policy-wins` / `block` -- how to resolve version conflicts on required packages. |
| `max_depth` | integer | `50` | Maximum transitive dependency depth. Must be `> 0`. |
| `require_pinned_constraint` | boolean | `false` | When `true`, every APM dep declared in `apm.yml` must use a bounded constraint (exact, `^`/`~`/bounded range, literal tag, or SHA). Transitive deps are also classified and pass when their parent manifests pinned them. Unbounded refs (missing ref, `*`, bare branch, bare `>=X.Y`) are routed through `policy.enforcement` (`warn` / `block`). **Enabling on existing projects will likely surface violations; roll out with `enforcement: warn` first.** |
| `require_pinned_constraint` | boolean | `false` | When `true`, every direct APM dep declared in `apm.yml` must use a bounded constraint (exact, `^`/`~`/bounded range, literal tag such as `v1.2.3` or `pkg-a-v1.2.3` for a package named `pkg-a`, or SHA). The declared constraint is judged, not the tag a range resolved to; transitive deps are not evaluated. Unbounded refs (missing ref, `*`, bare branch, bare `>=X.Y`) are routed through `policy.enforcement` (`warn` / `block`). **Enabling on existing projects will likely surface violations; roll out with `enforcement: warn` first.** |

### `require_pinned_constraint` reference

Expand All @@ -128,6 +128,8 @@ dependencies:
- sixth_eq/lib#=1.5.3 # OK: exact version (npm/cargo explicit equality)
- sixth_pip/lib#==1.5.3 # FAIL: pip-style operator not supported (BARE_BRANCH)
- seventh/lib#v1.5.3 # OK: literal tag
- acme/mono/packages/pkg-a#pkg-a-v1.5.3 # OK: this package's own release tag
- acme/mono/packages/pkg-a#pkg-b-v1.5.3 # FAIL: another package's tag (BARE_BRANCH)
- eighth/lib#aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa # OK: SHA
- ./packages/local # OK: local-path dep (no version surface)
```
Expand All @@ -144,6 +146,8 @@ Diagnostic shape (ASCII-only, ``[x]`` for block, ``[!]`` for warn):
- acme/lib: bare branch 'main' tracks a moving tip
```

A range is classified as the author wrote it: `fourth/lib#^1.2.0` stays `OK` after the install selects `v1.4.0` for it, and `acme/lib#main` stays `FAIL` whatever commit it resolves to. The package name used for `{name}-v{version}` and `{name}--v{version}` tags is the final path segment of a subdirectory dependency, otherwise the repository name.

Patterns are matched against `<owner>/<repo>` (or `<host>/<owner>/<repo>`). Wildcards via shell-style globs, e.g. `contoso/*`.

## mcp
Expand Down
5 changes: 5 additions & 0 deletions src/apm_cli/install/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,11 @@ class InstallContext:
# consumed by install/sources.py to plumb the resolution into the
# lockfile via InstalledPackage.git_semver_resolution.
git_semver_resolutions: dict[str, Any] = field(default_factory=dict)
# Manifest-declared refs of the direct deps, keyed by unique key. The
# resolve phase fills this before semver resolution rewrites a
# ``dep_ref.reference`` to the selected tag, so the policy gate can judge
# the authored constraint while everything else keeps the resolved ref.
declared_direct_refs: dict[str, str] = field(default_factory=dict)
# Run-scoped cache of RefResolver instances keyed by (host, token-fingerprint)
# so semver deps sharing an upstream repo reuse one ``git ls-remote`` tag
# listing (RefResolver memoizes per instance) instead of one per dep.
Expand Down
1 change: 1 addition & 0 deletions src/apm_cli/install/phases/policy_gate.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ def run(ctx: InstallContext) -> None:
fetch_outcome=fetch_result.outcome,
fail_fast=(enforcement == "block"),
direct_dep_keys={d.get_unique_key() for d in getattr(ctx, "all_apm_deps", []) or []},
declared_refs=getattr(ctx, "declared_direct_refs", None) or None,
**extra_kwargs,
)

Expand Down
1 change: 1 addition & 0 deletions src/apm_cli/install/phases/policy_target_check.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ def run(ctx: InstallContext) -> None:
fail_fast=False, # ensure target check runs even if dep checks re-pass
registries=registries_map,
direct_dep_keys={d.get_unique_key() for d in getattr(ctx, "all_apm_deps", []) or []},
declared_refs=getattr(ctx, "declared_direct_refs", None) or None,
)

# ------------------------------------------------------------------
Expand Down
8 changes: 8 additions & 0 deletions src/apm_cli/install/phases/resolve.py
Original file line number Diff line number Diff line change
Expand Up @@ -411,6 +411,14 @@ def _resolve_dependencies(

# 4. Tracking variables (phase-local except where noted)
direct_dep_keys = builtins.set(dep.get_unique_key() for dep in ctx.all_apm_deps)
# Keep the manifest-declared refs of the direct deps before resolution
# rewrites ``dep_ref.reference`` to a selected tag further down. The
# policy gate reads these so ``require_pinned_constraint`` judges what
# the author wrote; the resolved ref stays in place for every other
# consumer (download, drift, lockfile).
ctx.declared_direct_refs = {
dep.get_unique_key(): dep.reference for dep in ctx.all_apm_deps if dep.reference is not None
}
Comment on lines +419 to +421
callback_downloaded: builtins.dict = {}
pending_callback_downloads: builtins.dict[Path, tuple[str, str | None]] = {}
transitive_failures: builtins.list = []
Expand Down
88 changes: 84 additions & 4 deletions src/apm_cli/policy/_constraint_pinning.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@
* Caret / tilde / bounded semver ranges (``^1.2.3``, ``~1.2.3``,
``>=1.0 <2.0``, ``1.x``).
* Literal tag refs (``v1.2.3``, ``v1.2.3-beta.1``).
* Package-qualified literal tags in the layouts the git-semver resolver
selects from (``pkg-a-v1.2.3``, ``pkg-a--v1.2.3``), when ``pkg-a`` is
the dependency's own package name.
* Full 40-char SHA refs.

A dependency is "unbounded" when any of the following hold:
Expand All @@ -25,6 +28,11 @@
constraint string only; no remote calls and no subprocess. Used by
``policy.dependencies.require_pinned_constraint`` (see
``policy_checks.py::_check_pinned_constraints``).

The install pipeline rewrites ``dep.reference`` to the selected tag once
a semver range is resolved, so callers that run after that point pass
the manifest-declared ref through ``declared_ref`` and the classifier
never judges the replacement (#3224).
"""

from __future__ import annotations
Expand Down Expand Up @@ -53,6 +61,9 @@ class UnboundedReason(str, Enum):
# Literal version tag: ``v1.2.3`` / ``v1.2.3-rc.1`` / ``v1.2.3+meta``.
_LITERAL_TAG_RE = re.compile(r"^v\d+\.\d+\.\d+([-+][0-9A-Za-z.\-+]+)?$")

# Placeholder the git-dependency tag layouts use for the package name.
_NAME_PLACEHOLDER = "{name}"

# Wildcard token in any range component.
_WILDCARD_TOKEN_RE = re.compile(r"(^|\s)[\*xX](\s|$)")

Expand Down Expand Up @@ -114,24 +125,76 @@ def _classify_range(spec: str) -> UnboundedReason | None:
return None


def classify_unbounded_reason(dep: DependencyReference) -> UnboundedReason | None:
def package_tag_name(dep: DependencyReference) -> str | None:
"""Return the ``{name}`` the git-semver resolver matches tags with for *dep*.

Same rule as ``apm_cli.install.helpers.ref_reuse._git_semver_package_name``
and OpenAPM 7.3: the final virtual-path component for a selected
subdirectory package, otherwise the final repository path component
without a trailing ``.git``.
"""
virtual_path = getattr(dep, "virtual_path", None)
is_subdirectory = getattr(dep, "is_virtual_subdirectory", None)
if callable(is_subdirectory) and is_subdirectory() and virtual_path:
return virtual_path.rstrip("/").rsplit("/", 1)[-1] or None
Comment on lines +136 to +139
repo_url = getattr(dep, "repo_url", None) or ""
name = repo_url.rstrip("/").rsplit("/", 1)[-1]
if name.endswith(".git"):
name = name[: -len(".git")]
return name or None


def _is_package_literal_tag(spec: str, dep: DependencyReference) -> bool:
"""True when *spec* is a literal tag in a ``{name}`` layout for *dep*'s own name.

Reuses the resolver's tag layouts and regex builder, so the version
grammar is the one the resolver already selects with. ``{name}`` is
bound to this dependency's package name: ``pkg-b-v1.1.0`` on a
``pkg-a`` dependency, or ``anything-v1.1.0`` on a repository named
differently, is still a bare branch.
"""
name = package_tag_name(dep)
if not name:
return False
# Lazy imports keep the policy import graph free of resolver modules.
from apm_cli.deps.git_semver_resolver import DEFAULT_TAG_PATTERNS
from apm_cli.marketplace.tag_pattern import build_tag_regex

for pattern in DEFAULT_TAG_PATTERNS:
if _NAME_PLACEHOLDER not in pattern:
continue
if build_tag_regex(pattern, name=name).match(spec):
return True
return False


def classify_unbounded_reason(
dep: DependencyReference,
*,
declared_ref: str | None = None,
) -> UnboundedReason | None:
"""Return ``None`` if *dep*'s constraint is pinned, otherwise the reason.

``declared_ref`` is the manifest-declared ref when the caller knows
``dep.reference`` has since been rewritten to a resolved tag; when
given it is classified in place of ``dep.reference``.

Classification order (first match wins):

1. Local-path dep -> ``None`` (no version surface).
2. Registry dep + semver range -> range-shape check.
3. Empty / missing ref -> ``NO_REF``.
4. 40-char hex SHA -> ``None``.
5. Literal ``v\\d+\\.\\d+\\.\\d+`` tag -> ``None``.
5a. ``{name}-v…`` / ``{name}--v…`` tag for this dep's own name -> ``None``.
6. Parses as semver range -> range-shape check.
7. Else -> ``BARE_BRANCH``.
"""
# 1. Local-path deps have no constraint surface to pin.
if getattr(dep, "is_local", False):
return None

ref = getattr(dep, "reference", None)
ref = declared_ref if declared_ref is not None else getattr(dep, "reference", None)
source = getattr(dep, "source", None)

# Lazy import: avoid pulling marketplace.semver into policy import graph.
Expand Down Expand Up @@ -162,6 +225,14 @@ def classify_unbounded_reason(dep: DependencyReference) -> UnboundedReason | Non
if _LITERAL_TAG_RE.match(spec):
return None

# 5a. Package-qualified literal tag for this dependency's own name
# (``pkg-a-v1.1.0``, ``pkg-a--v1.1.0``) -> pinned. The resolver
# selects from exactly these layouts, and the dependency guide
# recommends pinning them. Any other ``...-vX.Y.Z`` string stays
# a bare branch below.
if _is_package_literal_tag(spec, dep):
return None

# 5b. Bare wildcard tokens ('*', 'x', 'X') -- handled before the
# semver-range probe because the registry grammar rejects them as
# standalone components, but they unambiguously express "any
Expand All @@ -184,13 +255,22 @@ def is_pinned_constraint(dep: DependencyReference) -> bool:
return classify_unbounded_reason(dep) is None


def humanize_reason(reason: UnboundedReason, dep: DependencyReference) -> str:
def humanize_reason(
reason: UnboundedReason,
dep: DependencyReference,
*,
ref: str | None = None,
) -> str:
"""Render a one-line actionable hint for the given reason.

``ref`` is the declared ref to name in the hint when the caller
classified something other than ``dep.reference``.

Emitted in ``[x]``/``[!]`` policy diagnostics; ASCII only per
``.github/instructions/encoding.instructions.md``.
"""
ref = getattr(dep, "reference", None) or ""
if ref is None:
ref = getattr(dep, "reference", None) or ""
if reason is UnboundedReason.NO_REF:
return "no ref; resolves to default branch"
if reason is UnboundedReason.BARE_BRANCH:
Expand Down
4 changes: 3 additions & 1 deletion src/apm_cli/policy/_help_text.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,9 @@
# .github/instructions/encoding.instructions.md.
REQUIRE_PINNED_CONSTRAINT_HELP = (
"When true, every direct APM dep must declare a bounded constraint "
"(exact version, '^'/'~'/bounded range, literal tag, or SHA). "
"(exact version, '^'/'~'/bounded range, literal tag such as 'v1.2.3' "
"or 'pkg-a-v1.2.3' for a package named pkg-a, or SHA). The declared "
"constraint is judged, not the tag a range resolved to. "
"Unbounded refs (missing ref, '*', bare branch, bare '>=X.Y') are "
"reported through 'policy.enforcement' (warn | block). "
"Default: false. Recommend rolling out with 'enforcement: warn' first."
Expand Down
Loading
Loading