feat(policy): require_pinned_constraint to ban unbounded dep ranges#1494
Conversation
Adds a new boolean field 'policy.dependencies.require_pinned_constraint'
to apm.policy.yml. When set to 'true', dependency policy checks flag any
direct APM dependency whose constraint is unbounded:
- NO_REF : ref missing / empty (tracks default branch)
- BARE_BRANCH : ref is a branch name ('main', 'develop', ...)
- WILDCARD : ref is '*', 'x', or 'X'
- OPEN_UPPER : range opens upward without paired upper (e.g. '>=1.0')
- GREATER_THAN_ONLY: range is a bare '>X.Y.Z'
Pinned (== bounded) constraints: exact versions, '^'/'~'/bounded ranges,
'X.Y.x' partial wildcards, literal 'vX.Y.Z' tags, 40-char SHAs, and
local-path deps (no version surface).
Default is false; opt-in via apm.policy.yml. Strict-wins inheritance
(once a parent policy enables it, child cannot relax). Routed through
the existing 'policy.enforcement' (warn | block | off). Runs from the
shared run_dependency_policy_checks seam so it fires at all four call
sites (policy_gate, policy_target_check, run_policy_checks,
run_policy_preflight).
The classification module operates on the declared constraint string
only -- deterministic, no I/O, no subprocess.
Closes #1491.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Adds an opt-in governance rule to apm.policy.yml that enforces “bounded/pinned” dependency constraints and surfaces violations through the existing policy.enforcement routing. This extends the policy system’s dependency checks with a deterministic, string-only classifier and associated test + documentation coverage.
Changes:
- Adds
policy.dependencies.require_pinned_constraint: boolto the policy schema, parser, and inheritance merge logic. - Introduces
_constraint_pinning.pyto classify “unbounded” constraints (missing ref, wildcard, bare branch, open-upper ranges) and provide actionable diagnostics. - Wires a new dependency-policy check (
dependency-pinned-constraint) intorun_dependency_policy_checks, with new unit + integration + E2E tests and docs updates.
Show a summary per file
| File | Description |
|---|---|
src/apm_cli/policy/schema.py |
Adds require_pinned_constraint to DependencyPolicy. |
src/apm_cli/policy/parser.py |
Validates/parses dependencies.require_pinned_constraint from YAML. |
src/apm_cli/policy/inheritance.py |
Merges the new field with strict-wins semantics (logical OR). |
src/apm_cli/policy/_constraint_pinning.py |
New classifier + reason enum + humanized hints for unbounded constraints. |
src/apm_cli/policy/policy_checks.py |
Adds _check_pinned_constraints() and hooks it into dependency policy checks. |
src/apm_cli/policy/_help_text.py |
Adds a help-text constant for the new policy field. |
tests/unit/policy/test_schema_pinned_constraint.py |
Verifies schema defaults, parsing, and inheritance behavior. |
tests/unit/policy/test_policy_checks.py |
Updates expected total check count to include the new check. |
tests/unit/policy/test_pinned_constraint.py |
Table-driven tests for classifier correctness + ASCII-only hints. |
tests/unit/policy/test_integration_pinned_constraint.py |
Ensures the new check runs through key policy-check seams. |
tests/integration/test_policy_pinned_constraint_e2e.py |
E2E apm install coverage for block vs warn behavior. |
docs/src/content/docs/reference/policy-schema.md |
Documents the field, examples, diagnostics, and merge rules. |
packages/apm-guide/.apm/skills/apm-usage/governance.md |
Updates governance scaffolding + merge/violation tables. |
Copilot's findings
- Files reviewed: 13/13 changed files
- Comments generated: 2
…aint Add require_pinned_constraint to sibling enterprise/consumer docs so the new field is consistent across all surfaces that enumerate dependencies.* policy fields: - enterprise/policy-reference.md: YAML scaffold, reference section, check-name row, inheritance merge row, line-552 Dependencies bullet, violation-class diagnostic row - enterprise/governance-guide.md: scope-matrix row, merge-diagram caption (require_pinned_constraint=OR) - enterprise/apm-policy-getting-started.md: YAML scaffold (keeps lockstep with governance.md scaffold) - consumer/governance-on-the-consumer-ramp.md: one-liner so consumers are not surprised by an install block Plus three small code-side fixes from the panel: - _constraint_pinning.py: precompile _PARTIAL_WILDCARD_RE at module scope (was recompiled per-component in _classify_range) - _help_text.py: drop stale '--why' / 'apm policy explain' forward reference (avoids documentation debt before those surfaces exist) - reference/policy-schema.md: clarify scope - the check classifies every dep declared in apm.yml (transitives pass because their parents pinned them), not just 'direct' deps Lint clean (ruff, ruff format, pylint R0801, auth-signals). 65 new policy tests + 1 xfailed (the #1488 sentinel) still pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
APM Review Panel:
|
| Persona | B | R | N | Takeaway |
|---|---|---|---|---|
| Python Architect | 0 | 0 | 2 | Architecturally clean: well-placed module, enum + pure-function pattern, lazy imports, correct strict-wins, thorough coverage. |
| CLI Logging Expert | 0 | 0 | 1 | Diagnostic shape matches established CheckResult voice; ASCII enforced; hints actionable. |
| DevX UX Expert | 0 | 0 | 2 | No new CLI surface; behavior gated by opt-in YAML; hints are concrete and copy-pasteable. |
| Supply-Chain Security | 0 | 3 | 0 | Classification is sound; scope-clarity and MCP-extension worth following up post-merge. |
| OSS Growth Hacker | 0 | 0 | 2 | Theme 3 moat-extension beat; ship-and-amplify in the next release note. |
| Doc Writer | 0 | 4 | 2 | Sibling-page drift identified and folded in this round; corpus consistent. |
| Test Coverage Expert | 0 | 0 | 0 | All critical surfaces covered at integration-with-fixtures tier; e2e CliRunner block/warn both green. |
B = blocking-severity findings, R = recommended, N = nits. Counts are signal strength, not gates. The maintainer ships.
Top 3 follow-ups
- [Supply-Chain Security] Extend
_check_pinned_constraintsto MCP dependency declarations -- MCP deps bypass the new classifier today; filing a tracked issue prevents a silent coverage gap as MCP adoption grows. - [Supply-Chain Security] Add doc-comment clarifying that literal-tag classification relies on lockfile SHA for integrity against mutable refs -- without it, a future contributor may assume tag-pinning alone is sufficient; the lockfile is the actual integrity boundary.
- [OSS Growth Hacker] Update README inline governance list and add a cross-link from
policy-reference.mdto the new constraint check -- separable launch amplification work; deferred from this PR to keep the diff focused but should land before the 0.15 release note.
Architecture
classDiagram
direction LR
class UnboundedReason {
<<Enum>>
+NO_REF str
+BARE_BRANCH str
+WILDCARD str
+OPEN_UPPER str
+GREATER_THAN_ONLY str
}
class DependencyPolicy {
<<ValueObject>>
+allow tuple
+deny tuple
+require tuple
+require_pinned_constraint bool
+max_depth int
}
class DependencyReference {
<<ValueObject>>
+reference str
+source str
+is_local bool
}
class CheckResult {
<<ValueObject>>
+name str
+passed bool
+message str
+details list
}
class classify_unbounded_reason {
<<Pure>>
}
class humanize_reason {
<<Pure>>
}
class _check_pinned_constraints {
<<Pure>>
}
class run_dependency_policy_checks {
<<IOBoundary>>
}
_check_pinned_constraints ..> classify_unbounded_reason : per dep
_check_pinned_constraints ..> humanize_reason : format hint
_check_pinned_constraints ..> DependencyPolicy : reads field
_check_pinned_constraints ..> CheckResult : returns
classify_unbounded_reason ..> DependencyReference : inspects
classify_unbounded_reason ..> UnboundedReason : returns
run_dependency_policy_checks *-- _check_pinned_constraints
flowchart TD
A["apm install / apm audit"] --> B["run_dependency_policy_checks"];
B --> C{"require_pinned_constraint?"};
C -- false --> D["passed (disabled)"];
C -- true --> E["per dep: classify_unbounded_reason"];
E --> F{"is_local?"};
F -- yes --> P["pinned"];
F -- no --> G{"source == registry?"};
G -- yes --> H["semver range -> _classify_range"];
G -- no --> I{"empty ref?"};
I -- yes --> J["NO_REF"];
I -- no --> K{"40-char SHA?"};
K -- yes --> P;
K -- no --> L{"literal tag v1.2.3?"};
L -- yes --> P;
L -- no --> M{"bare *, x, X?"};
M -- yes --> N["WILDCARD"];
M -- no --> O{"is_semver_range?"};
O -- yes --> H;
O -- no --> Q["BARE_BRANCH"];
H --> R{"shape"};
R -- bounded --> P;
R -- ">=X" --> S["OPEN_UPPER"];
R -- ">X" --> T["GREATER_THAN_ONLY"];
R -- wildcard --> N;
E --> U["humanize_reason -> violation"];
U --> V["CheckResult aggregated -> CIAuditResult"];
Full per-persona findings
python-architect (active, 2 nits)
- nit (
src/apm_cli/policy/_constraint_pinning.py:133): Lazy import ofis_semver_rangeinsideclassify_unbounded_reasonruns per-dep in a loop. Module cache makes the cost negligible; moving the import to the caller (_check_pinned_constraints) outside the loop would make the one-time cost explicit. Not folded -- current placement keeps lazy-import discipline at the boundary that needs it. - nit (
src/apm_cli/policy/_constraint_pinning.py:99):re.match(r"^\d+\.\d+\.[xX*]$", p)recompiled per iteration. FOLDED IN as_PARTIAL_WILDCARD_REmodule constant.
cli-logging-expert (active, 1 nit)
- nit (
src/apm_cli/policy/policy_checks.py):"dependency(ies)"parenthetical plural reads like machine output. NOT folded -- the exact same pattern is used by the sibling allow/deny/max_depth checks in the same file; touching only the new one would create inconsistency, and refactoring all four is out of scope.
devx-ux-expert (active, 2 nits)
- nit (
src/apm_cli/policy/_constraint_pinning.py):<X.Yin the hint reads as a shell redirect when copy-pasted into a terminal. Kept -- the hint is quoted ('<X.Y') in the rendered diagnostic. - nit (
src/apm_cli/policy/_help_text.py:21): Stale--why/apm policy explainforward reference. FOLDED IN by dropping the reference.
supply-chain-security-expert (active, 3 recommended)
- recommended: Literal-tag regex accepts mutable git refs. Doc-comment that tag-pinning relies on lockfile SHA for integrity. Deferred as follow-up.
- recommended: MCP deps are not passed through
_check_pinned_constraints. Deferred as follow-up issue. - recommended: Docstring "direct deps only" vs "transitives also classified" -- FOLDED IN by updating the schema reference to clarify scope.
oss-growth-hacker (active, 2 nits + growth note)
- nits: README inline-list amplification + reference-page cross-link. Deferred as launch amplification work.
- growth_strategy_note: Theme 3 launch story is enterprise-shaped and ready; CHANGELOG entry should lead with the governance unlock. Captured above under Growth signal.
auth-expert (inactive)
- inactive_reason: PR touches only
src/apm_cli/policy/*anddocs/tests-- pure constraint-classification logic with no token, credential, host classification, or remote-auth code paths.
doc-writer (active, 4 high + 2 low)
- high:
enterprise/policy-reference.mdmissing the new field across YAML scaffold, reference section, check-name row, inheritance merge row, line-552 Dependencies bullet, violation-class row. FOLDED IN. - high:
enterprise/governance-guide.mdmissing scope-matrix row and merge-diagram caption. FOLDED IN (require_pinned_constraint=ORadded). - high:
enterprise/apm-policy-getting-started.mdscaffold missing the field. FOLDED IN. - high:
consumer/governance-on-the-consumer-ramp.mdmissing consumer-facing one-liner. FOLDED IN. - low: "direct" wording in reference page accuracy nit. FOLDED IN.
- low: Pre-existing em-dash in unrelated content. Out of scope -- separable encoding cleanup pass.
test-coverage-expert (active, 0 findings)
- evidence: 7 passed test references including e2e CliRunner integration-with-fixtures tier for block and warn modes, and a structural proof that
policy_target_checkfilter (line 92) prevents double-emission.
Advisory pass generated by apm-review-panel. The panel surfaces signal; the maintainer ships. No labels modified.
|
All panel blocking findings folded (panel reported zero blocking, all recommended/nits either folded in c22995f's history or explicitly deferred as post-merge follow-ups). CI green on the merge-with-main commit. Lint silent. Ready to merge. Follow-ups from the apm-review-panel pass have landed. Summary:
Deferred as separable follow-ups (panel-tagged, not blocking):
Lint contract (all 4 silent / exit 0 on c22995f):
Targeted policy test re-run (post-merge): CI evidence on c22995f: 13 SUCCESS, 1 SKIPPED (deploy), 0 FAILURE. Run: https://github.com/microsoft/apm/actions/runs/26506131520 Ready for maintainer review. |
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Copilot review on #1494 flagged that _check_pinned_constraints() iterated the full resolved set (including transitives) passed by run_dependency_policy_checks(). A transitive package declaring 'main', '*', or '>=X' in its own manifest is not actionable by the consumer, but enabling require_pinned_constraint would fail their install. Thread the direct-dep set (DependencyReference.get_unique_key() values) through run_dependency_policy_checks into the pinned check. Wire policy_gate, policy_target_check, and run_policy_preflight to pass it; legacy/dep-only seams (direct_dep_keys=None) keep the prior behavior. Audit wrapper already iterates direct-only manifest deps, so no change there. Regression test added with mutation-break gate verified: deleting the filter line makes the new transitive-skip test fail, restoring it passes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
Copilot review follow-up: 1 thread addressed (require_pinned_constraint now restricted to direct deps via threaded |
…#1494) (#1505) Adds 6 CliRunner-based e2e tests that close the user-promise gaps left by #1494's existing unit + partial-e2e coverage: - Promise B: pinned dep (caret range, bare exact version) passes the policy gate under enforcement=block + require_pinned_constraint=true. - Promise C: policy_gate forwards direct_dep_keys to the runner -- regression trap for the transitive bleed-through guard added in the #1494 Copilot follow-up. - Promise D + G: block exits with code 1 and the diagnostic cites the offending dep ref, the pinning hint, and the check name inside the violation block (not just upstream resolver noise). - Promise E: backward compat -- require_pinned_constraint=false (the default) does NOT block an unbounded dep, even at enforcement=block. - Promise F: --dry-run previews a 'Would be blocked by policy' line citing the dep without aborting or mutating the filesystem. Each test was verified with a mutation-break gate: removing the production guard makes the corresponding test fail; restoring it passes (see PR body for the 5 mutations + evidence). Surfaces a separate observation worth filing: the '=1.2.3' alternate exact-version syntax is currently classified as BARE_BRANCH by _constraint_pinning.py (only bare '1.2.3' is recognized). The e2e uses the bare form per the documented contract. No production code changes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
feat(policy):
require_pinned_constraintto ban unbounded dep rangesCloses #1491.
TL;DR
Adds a new boolean field
policy.dependencies.require_pinned_constrainttoapm.policy.yml. Whentrue, dependency policy checks flag any direct APM dependency whose constraint is unbounded (missing ref,*, bare branch, bare>=X.Y) and route the violation through the existingpolicy.enforcement(warn | block | off).Default is
false. No behaviour change for existing users until they opt in.Why (governance problem this closes)
Lockfiles deliver per-install reproducibility, but the
apm.ymlmanifest itself can express intent that defeats lockfile discipline on the nextapm update:Enterprise audit flows want to declare "we only ship deps that pin to a bounded constraint." Today no built-in policy field expresses that intent; the only workaround is custom CI scripting per repo. This PR closes that gap.
What changed
src/apm_cli/policy/schema.pyrequire_pinned_constraint: bool = FalseonDependencyPolicysrc/apm_cli/policy/parser.pysrc/apm_cli/policy/inheritance.pyparent OR child)src/apm_cli/policy/_constraint_pinning.py(NEW)UnboundedReasonenum +classify_unbounded_reason()/is_pinned_constraint()/humanize_reason()src/apm_cli/policy/policy_checks.py_check_pinned_constraints()+ wire-up inrun_dependency_policy_checkssrc/apm_cli/policy/_help_text.pyREQUIRE_PINNED_CONSTRAINT_HELPconstanttests/unit/policy/test_pinned_constraint.py(NEW)tests/unit/policy/test_schema_pinned_constraint.py(NEW)tests/unit/policy/test_integration_pinned_constraint.py(NEW)tests/integration/test_policy_pinned_constraint_e2e.py(NEW)apm installwith block + warn modesdocs/src/content/docs/reference/policy-schema.mdpackages/apm-guide/.apm/skills/apm-usage/governance.mdClassification rules (single source of truth)
Implemented in
_constraint_pinning.py. The classifier operates on the declared constraint string only -- no I/O, no subprocess, deterministic, O(1) per dep.Order (first match wins):
dep.is_local-> pinned (no version surface)dep.source == "registry"-> range-shape check (registry resolver also pins viaresolved_hashin the lockfile)dep.reference is None | ""->NO_REFv\d+\.\d+\.\d+...tag -> pinned*/x/X->WILDCARDis_semver_range(spec)matches -> range-shape (OPEN_UPPER/GREATER_THAN_ONLY/ pinned)BARE_BRANCHRange-shape (
_classify_range) treats a range as bounded iff at least one component pins the upper edge (<,<=,^,~, partialX.Y.x, or a bare exact version).Why a built-in field, not
custom_checks(#519)Per the issue body and the design pack convergence note. Documenting the rationale here so reviewers don't have to dig:
allow/deny/requireas a first-class policy surface, not buried in a per-projectcustom_checksblock.custom_checksis for cases that genuinely need to shell out.custom_checkswould impose subprocess overhead for the same answer.custom_checksvalidator on top of the built-in pinning gate. They are not mutually exclusive.Where the check runs (four call sites)
Wired once at the seam (
run_dependency_policy_checksinpolicy_checks.py), so it fires at every dependency-policy entry point introduced by #1471:install/phases/policy_gate.py(install-time gate, primary surface)install/phases/policy_target_check.py(post-targets pass; check runs but is filtered out -- the gate already surfaced any violation; no double-emission)policy/policy_checks.py::run_policy_checks(apm audit --ciwrapper)policy/install_preflight.py::run_policy_preflightTest
test_policy_pinned_check_runs_at_all_four_call_sitesparametrizes over the four call patterns and confirms thedependency-pinned-constraintcheck name appears in every result.Soft dependency on #1488 (git-source semver routing)
The classifier is shape-based, so it already handles
acme/lib#^1.2.0regardless of source. Today that ref reaches the policy seam unchanged; the classifier sees^1.2.0, recognises it as a bounded semver range, and reports the dep as pinned.test_classify_git_semver_dep_returns_pinned_noneis markedxfail(strict=False, reason="awaits #1488...")and contains a sentinelraise AssertionErrorthat fires the moment asource="git-semver"discriminator is wired in the resolver -- signalling that the xfail can be removed and the test promoted to a hard regression trap.test_classify_marketplace_dep_with_caret_returns_pinned_nonepins the same shape-based invariant for the marketplace source (will route uniformly once PR #1422 lands).SHA pins are pinned (rationale for the security panel)
SHAs are deterministic. The policy is about constraint expressiveness ("does this dep declare a bounded version surface?"), not source provenance ("did the author audit this commit before pinning?"). A separate policy field (or a custom check via #519) can express the stricter provenance question without overloading this knob.
Trade-offs
dep.referencewhich the consumer authored. Transitive deps are pinned by their parent's manifest; the consumer has no way to rewrite that constraint. Adding a transitive variant is a follow-up; the design pack flags it asrequire_pinned_constraint_transitive: bool = Falseif demand surfaces.direct | transitivesplit materialises. Holding this line unless a NEW technical reason surfaces.mainon internal repos). If demand surfaces, follow up withrequire_pinned_constraint: {except: [...]}.Validation evidence
Tests (post-change)
Full unit suite:
15595 passed, 1 skipped, 21 xfailed.Full policy integration suite:
59 passed.Lint (canonical contract, all seven gates)
YAML I/O guard, file-length cap (
policy_checks.pyis 1124 lines, under the 2450 cap), andrelative_togrep guards: not touched by this PR (no new file I/O, no new path manipulation).How to test
Switch
enforcement: warnto verify install proceeds with the same diagnostic at[!]severity.Out of scope (intentional)
except:allowlist for approved unbounded patterns.resolved_hashper feat(registry): [FEATURE] Add package registry support for dependency… #1471).