Summary
apm install does not enforce apm-policy.yml. Today, organisational policy is only enforced by apm audit --ci. A developer running apm install on their workstation can pull and integrate dependencies that the org policy explicitly denies, with no warning and no failure.
The discovery, parsing, and enforcement primitives all already exist in src/apm_cli/policy/ — they just aren't wired into the install pipeline.
Evidence
src/apm_cli/commands/install.py — no references to discover_policy, run_policy_checks, or ApmPolicy.
src/apm_cli/install/phases/resolve.py — same: zero policy hooks. The only policy-adjacent code (install/insecure_policy.py) handles the unrelated HTTP-dependency consent prompt.
src/apm_cli/commands/audit.py:507-540 — the only call site of discover_policy() + run_policy_checks(). Honours enforcement: block | warn | off.
src/apm_cli/policy/policy_checks.py:702-760 — run_policy_checks() already evaluates dependency allowlist, denylist, required packages, required version, etc. against apm.yml + lockfile.
Impact
The current model relies on apm audit --ci being a required CI check on every PR. That assumption is brittle:
- Local-only workflows bypass the gate entirely. Developers running
apm install against a private branch, a fork, or a personal scratch repo never hit CI, so banned packages install silently.
- Transitive dependencies bypass the gate at install time. A policy-allowed direct dep can pull a denied transitive dep; the user only learns at audit time, after the file system already has the integrated content (and
.github/ files have been deployed).
- The integration step has already happened by the time CI fails. Files have been written to
.github/prompts/, .github/skills/, etc. CI tells you "this should not have been installed" but the developer's working tree is already polluted.
- Mismatch with developer mental model. Coming from
npm, pip, cargo, users reasonably expect the package manager itself to enforce org policy at install time, not a separate audit command.
Proposed approach (high level)
- Call
discover_policy(project_root) early in install/phases/resolve.py, after apm.yml parse and after the dependency graph is resolved (so transitive deps are also evaluable).
- Run the dependency-scoped subset of
run_policy_checks (_check_dependency_allowlist, _check_dependency_denylist, _check_required_package_version, plus the MCP equivalents) against the resolved set.
- Map
enforcement levels:
block -> fail the install before any download or file integration.
warn -> print a deferred-diagnostics summary and continue.
off -> skip entirely.
- Honour an escape hatch (e.g.
--no-policy flag plus an APM_POLICY=off env var) for offline / break-glass scenarios. Log loudly when used.
- Use the existing policy cache (
apm_modules/.policy-cache/) so the install path is not network-bound on every invocation.
- Apply the gate uniformly to:
apm install, apm install <pkg>, apm install --mcp, and apm update. (Currently only audit --ci gates these.)
Open questions
- Default-deny on policy fetch failure? If the org policy URL is unreachable (offline, network error), do we fail closed or fall back to permissive? Audit today errors out (
commands/audit.py:526); install probably wants the same behaviour when a project has previously seen a policy (cache hit), and a configurable behaviour when no cache exists.
apm install --no-cache behaviour? Should it also bust the policy cache, or keep policy cached separately to avoid surprise enforcement changes mid-install?
- First-run UX. When a developer clones a repo for the first time and runs
apm install, the policy fetch is the very first network call. We should make sure errors here are clearly distinguished from dependency-download errors.
- Where does the gate live architecturally? Most natural: a new phase between
resolve and download (e.g. install/phases/policy_gate.py), composed into the existing pipeline (install/pipeline.py). Mirrors the _check_insecure_dependencies pattern that already runs at the end of resolve.
- Telemetry / diagnostics surface. Use
DiagnosticCollector + CommandLogger.error() so blocked installs render a grouped, scannable summary consistent with the rest of apm install output.
Out of scope for this issue
- Changes to the policy schema itself (
policy/schema.py) — the dependencies field is already sufficient.
- Changes to
apm audit --ci behaviour — it stays the source of truth for CI gating; install enforcement is additive.
- Discovery model changes (auto-discovery via
<org>/.github/apm-policy.yml) — already correct, just needs to be invoked.
References
Summary
apm installdoes not enforceapm-policy.yml. Today, organisational policy is only enforced byapm audit --ci. A developer runningapm installon their workstation can pull and integrate dependencies that the org policy explicitly denies, with no warning and no failure.The discovery, parsing, and enforcement primitives all already exist in
src/apm_cli/policy/— they just aren't wired into the install pipeline.Evidence
src/apm_cli/commands/install.py— no references todiscover_policy,run_policy_checks, orApmPolicy.src/apm_cli/install/phases/resolve.py— same: zero policy hooks. The only policy-adjacent code (install/insecure_policy.py) handles the unrelated HTTP-dependency consent prompt.src/apm_cli/commands/audit.py:507-540— the only call site ofdiscover_policy()+run_policy_checks(). Honoursenforcement: block | warn | off.src/apm_cli/policy/policy_checks.py:702-760—run_policy_checks()already evaluates dependency allowlist, denylist, required packages, required version, etc. againstapm.yml+ lockfile.Impact
The current model relies on
apm audit --cibeing a required CI check on every PR. That assumption is brittle:apm installagainst a private branch, a fork, or a personal scratch repo never hit CI, so banned packages install silently..github/files have been deployed)..github/prompts/,.github/skills/, etc. CI tells you "this should not have been installed" but the developer's working tree is already polluted.npm,pip,cargo, users reasonably expect the package manager itself to enforce org policy at install time, not a separateauditcommand.Proposed approach (high level)
discover_policy(project_root)early ininstall/phases/resolve.py, afterapm.ymlparse and after the dependency graph is resolved (so transitive deps are also evaluable).run_policy_checks(_check_dependency_allowlist,_check_dependency_denylist,_check_required_package_version, plus the MCP equivalents) against the resolved set.enforcementlevels:block-> fail the install before any download or file integration.warn-> print a deferred-diagnostics summary and continue.off-> skip entirely.--no-policyflag plus anAPM_POLICY=offenv var) for offline / break-glass scenarios. Log loudly when used.apm_modules/.policy-cache/) so the install path is not network-bound on every invocation.apm install,apm install <pkg>,apm install --mcp, andapm update. (Currently onlyaudit --cigates these.)Open questions
commands/audit.py:526); install probably wants the same behaviour when a project has previously seen a policy (cache hit), and a configurable behaviour when no cache exists.apm install --no-cachebehaviour? Should it also bust the policy cache, or keep policy cached separately to avoid surprise enforcement changes mid-install?apm install, the policy fetch is the very first network call. We should make sure errors here are clearly distinguished from dependency-download errors.resolveanddownload(e.g.install/phases/policy_gate.py), composed into the existing pipeline (install/pipeline.py). Mirrors the_check_insecure_dependenciespattern that already runs at the end of resolve.DiagnosticCollector+CommandLogger.error()so blocked installs render a grouped, scannable summary consistent with the rest ofapm installoutput.Out of scope for this issue
policy/schema.py) — thedependenciesfield is already sufficient.apm audit --cibehaviour — it stays the source of truth for CI gating; install enforcement is additive.<org>/.github/apm-policy.yml) — already correct, just needs to be invoked.References
src/apm_cli/commands/audit.py:507-540src/apm_cli/policy/discovery.pysrc/apm_cli/policy/policy_checks.py:702-760