Repository navigation
fix(audit): fail closed when an explicit --policy cannot be loaded - #3230
Open
Daniel Meppiel (danielmeppiel) wants to merge 5 commits into
Open
Daniel Meppiel (danielmeppiel) wants to merge 5 commits into
Daniel Meppiel (danielmeppiel) wants to merge 5 commits into
Conversation
`apm audit --ci --policy <source>` exited 0 when the named policy was unreachable, missing, malformed, or a garbage response, because the #829 fail-open default for discovered policy also applied to explicit sources. An explicit path, https URL, or owner/repo now exits 1 with `[x] Policy '<source>' could not be loaded: <cause>`, regardless of policy.fetch_failure_default. Auto-discovery and `--policy org` keep the warn default; a stale-but-valid cached policy is still enforced. Unambiguous local paths that do not exist no longer fall through to a GitHub repo lookup. CI JSON and SARIF now report a `policy` object (enforced, source, enforcement). Fixes #3195 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 07c9d3c0-e774-4bd9-b7d0-09e0e276fc1c
Daniel Meppiel (danielmeppiel)
requested a review
from Sergio Sisternes (sergio-sisternes-epam)
as a code owner
October 14, 2026 13:48
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 07c9d3c0-e774-4bd9-b7d0-09e0e276fc1c
Contributor
There was a problem hiding this comment.
🟡 Changes recommended
Path handling and documentation contain unresolved edge cases, one test violates the URL assertion rule, and human scope approval is still pending.
4 open findings
What changed in this PR
Makes explicit audit policies fail closed when unavailable while preserving discovery defaults.
Changes:
- Fails explicit policy load errors with exit 1.
- Adds policy status to JSON/SARIF.
- Updates tests, documentation, and changelog.
| File | Description |
|---|---|
src/apm_cli/commands/audit.py |
Adds fail-closed routing and reports. |
src/apm_cli/policy/discovery.py |
Detects missing local policies. |
tests/unit/test_audit_policy_command.py |
Covers audit behavior and output. |
tests/unit/policy/test_discovery.py |
Covers path classification. |
packages/apm-guide/.apm/skills/apm-usage/governance.md |
Documents governance behavior. |
packages/apm-guide/.apm/skills/apm-usage/commands.md |
Updates command guidance. |
docs/src/content/docs/reference/manifest-schema.md |
Clarifies configuration scope. |
docs/src/content/docs/reference/cli/audit.md |
Documents output and exit codes. |
docs/src/content/docs/enterprise/policy-reference.md |
Details policy failure semantics. |
docs/src/content/docs/enterprise/governance-guide.md |
Updates operational guidance. |
docs/src/content/docs/enterprise/enforce-in-ci.md |
Documents CI enforcement. |
CHANGELOG.md |
Records the fix. |
🧠 Review effort: Balanced
💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Report existing non-file paths as 'not a file', match YAML suffixes case-insensitively, document the --policy org exception for fetch_failure_default, and compare URL sources by parsed components. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 07c9d3c0-e774-4bd9-b7d0-09e0e276fc1c
Spec-citation fold for the #3195 behaviour under the policy-discovery critical path: an explicit --policy ref that cannot be resolved, fetched, or parsed fails the governance operation non-zero; a valid stale-cache policy still counts as resolved. Adds the anchor, both enumerations, Appendix C row, manifest entry, behavioural conformance tests, version-history row, and regenerated CONFORMANCE.{md,json}. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 07c9d3c0-e774-4bd9-b7d0-09e0e276fc1c
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 07c9d3c0-e774-4bd9-b7d0-09e0e276fc1c
Daniel Meppiel (danielmeppiel)
added a commit
that referenced
this pull request
Oct 14, 2026
PR #3230 takes req-pl-021 and revision 0.1.46; this fold now uses req-pl-022 and revision 0.1.47 so both PRs merge in either order. Manifest, tests, and CONFORMANCE regenerated. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 38d136ad-4cf1-4ac6-8a20-9d8ba4c15a4a
11 of 17 tasks
This branch has not been deployed
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.


Description
TL;DR
apm audit --ci --policy <source>exited 0 when the named policy could not be loaded: an unreachable URL, a missing file, or a malformed or garbage policy. It only printedenforcement skipped. An explicit path,https://URL orowner/reponow fails the gate with exit 1 and an[x]message that names the source and the cause. Auto-discovery and--policy orgkeep the #829 warn default. CI JSON and SARIF now also report whether a policy was actually enforced.Important
This changes an observable exit code on a governance gate. It is a bug fix: the old behaviour turned a typo, a missing file or a network failure into a green "compliant" result.
Problem (WHY)
apm audit --ci --policy https://nonexistent.invalid/policy.yml→[!] Policy fetch failed … enforcement skipped, exit 0.apm audit --ci --policy ./missing-policy.yml→ exit 0 with no warning at all. The path was not a file, so discovery treated it as anowner/reporef. It made a GitHub lookup fororg:./missing-policy.yml, which came backabsent, and explicitabsentfell through silently.enforcement: bogus) →malformed→ warn, exit 0.-f jsongave no signal.Why this matters: #829 made fetch failures fail open so that auto-discovered org policy does not block developers on a network blip. An explicit
--policyis the caller saying "enforce exactly this". The project'spolicy.fetch_failure_defaultis not a usable lever there, because the audited project may not belong to the caller (see #3195).Approach (WHAT)
_audit_ci_gate, when--policyis an explicit source (anything exceptorg) and discovery returns no policy object, print[x] Policy '<source>' could not be loaded: <cause>to stderr and exit 1, before the fetch-failure-default branch.fetch_result.found, not on outcome literals. Every non-loading outcome (cache_miss_fetch_fail,malformed,garbage_response,absent,hash_mismatch,incomplete_chain, refusedhttp://) fails closed.cached_staleandemptystill carry a valid policy, so they still load.discover_policy(canonical owner of the--policyforms) returnsabsent+Policy file not found: <path>for an unambiguous local path that does not exist. It no longer makes a GitHub lookup for it.owner/name.ymlstays ambiguous and is still tried as a repo.policy: {enforced, source, enforcement}. SARIF adds the same object underruns[0].invocations[0].properties.policy.APM_POLICY_DISABLE=1with an explicit--policystays an operator override. It now prints a[!]line instead of skipping silently.Implementation (HOW)
src/apm_cli/commands/audit.py: new helpers_is_explicit_policy_source,_explicit_policy_load_reasonand_policy_enforcement_report. One new fail-closed block before the existing [BUG] _parse_remote_url fails for SSH remotes with non-git usernames #1159 routing; that routing is untouched for auto-discovery and--policy org. Output follows the gate's existing convention (click.echo(err=True)withSTATUS_SYMBOLS) so JSON and SARIF on stdout stay parseable. Sources are passed through_redact_policy_ref, so URL credentials and query strings never reach the message.src/apm_cli/policy/discovery.py:_is_unambiguous_local_policy_path, plus an earlyabsentreturn indiscover_policy. Install never passespolicy_override, so install-time behaviour is unchanged.apm policy status --policy-source ./missing.ymlnow shows a clear "Policy file not found" discovery error instead of a bogus repo lookup.req-pl-021, proposed 0.1.46): the Mode-B gate flaggedpolicy/discovery.pyas a critical path, and this is new observable governance behaviour on the existingreq-pl-001explicit-ref path, so it gets a spec citation rather than a waiver. The PR adds a §6.1 governance MUST, both enumerations, the Appendix C row, the manifest entry, two@pytest.mark.req("req-pl-021")behavioural tests intests/spec_conformance/test_policy_reqs.py, and regeneratedCONFORMANCE.{md,json}. Statement count goes from 128 to 129.policy-reference.md(§9.5, §9.5.1, §9.8),governance-guide.md(§9 and §10 failure table),enforce-in-ci.md,reference/cli/audit.md(flag row, exit codes, JSON/SARIF field),manifest-schema.md(fetch_failure_defaultscope), andapm-usage/governance.md+commands.md.Diagram
Legend: how
apm audit --cinow routes a policy fetch result. The boxed node is new; everything else is the existing #829/#1159 path.flowchart TD A["apm audit --ci"] --> B{"--policy given?"} B -->|"no"| C["discover_policy_with_chain (auto)"] B -->|"org"| C2["discover_policy_with_chain (org)"] B -->|"path / URL / owner-repo"| D["discover_policy_with_chain (explicit)"] D --> E{"policy object resolved?"} E -->|"yes (found, empty, cached_stale)"| G["run policy checks"] E -->|"no"| X["NEW: [x] could not be loaded, exit 1"] C --> F{"fetch failed or no policy?"} C2 --> F F -->|"yes"| H{"fetch_failure_default"} H -->|"warn"| W["[!] enforcement skipped, baseline only"] H -->|"block"| Y["[x] exit 1"] F -->|"no"| G style X fill:#fdd,stroke:#c00Trade-offs
--policy-fetch-failure blockswitch from the issue. An explicit source that cannot load is never a valid pass, and a flag would keep the unsafe default.--policy orgkeeps discovery semantics. It is explicitly out of scope ([BUG] apm audit --ci --policy <source> exits 0 when that explicitly requested policy cannot be loaded #3195). It is still governed bypolicy.fetch_failure_default.discover_policy, not in audit.discover_policyalready owns the--policyform contract (_help_text.POLICY_SOURCE_FORMS_HELP). Classifying paths in audit would create a second authority.absentnow fails. The old docs said explicit--policy"falls through"absent. A 404 URL, a missing file or a repo withoutapm-policy.ymlall mean "the requested policy does not exist", so they fail. A present-but-emptypolicy still loads and passes.hash_mismatchinapm audit --cistill follows the warn/block knob. The docs say it is always fail-closed. That is a separate discovery-path fix (out of scope here).Benefits
./missing-policy.ymlno longer makes a pointless GitHub API call, and the error says "Policy file not found" instead of nothing.policy.enforcedfrom JSON or SARIF instead of inferring it from check counts (the workaround from [BUG] apm audit --ci --policy <source> exits 0 when that explicitly requested policy cannot be loaded #3195).fetch_failure_defaultsemantics, are unchanged.Validation
Manual repro on the fixed build (scratch project with apm.yml + apm.lock.yaml)
Each failing case also prints:
--policy was passed explicitly, so the audit fails closed instead of skipping enforcement. Fix the policy source above and re-run.Tests and CI-mirror lint
Scenario evidence
--policy https://…that is unreachable, 404s, or returns HTML fails the gatetests/unit/test_audit_policy_command.py::TestExplicitPolicyLoadFailureFailsClosed::test_unreachable_url_exits_1…::test_url_404_exits_1…::test_garbage_url_response_exits_1requests.getpatched)--policy ./missing.yml(relative, absolute, any suffix case) or a directory fails without a remote lookup…::test_missing_local_file_exits_1_without_remote_lookup[True/False]tests/unit/policy/test_discovery.py::TestDiscoverPolicy::test_override_missing_local_path_skips_repo_lookup(incl.missing.YML)…::test_override_existing_directory_reports_not_a_file…::test_malformed_local_policy_exits_1[garbage-yaml/not-a-mapping/schema-invalid]--policy owner/repowith noapm-policy.ymlfails;owner/name.ymlis still tried as a repo…::test_repo_without_policy_exits_1tests/unit/policy/test_discovery.py::TestDiscoverPolicy::test_override_owner_repo_with_yaml_suffix_still_tries_repofetch_failure_default: warndoes not reopen an explicit failure…::test_project_warn_default_does_not_reopen_explicit_failure…::test_stale_but_valid_cached_policy_is_still_enforced--policy orgfetch failures still warn and exit 0tests/unit/test_audit_policy_command.py::TestDiscoveryFetchFailureUnchanged::test_discovery_fetch_failure_still_warns_and_exits_0[auto/org]existing
tests/unit/test_audit_ci_auto_discovery.py,tests/integration/test_audit_silent_skip_e2e.pytests/unit/test_audit_policy_command.py::TestPolicyEnforcementReport::*(4 tests)…::test_failure_message_redacts_url_credentialsAPM_POLICY_DISABLE=1with explicit--policywarns instead of skipping silently…::test_policy_disable_env_warns_and_skipsHow to test
apm.yml+apm.lock.yaml:apm audit --ci --policy ./missing-policy.yml; echo $?→[x] … Policy file not found,1.apm audit --ci --policy https://nonexistent.invalid/policy.yml; echo $?→[x] … Connection error …,1.printf 'enforcement: bogus\n' > bad.yml && apm audit --ci --policy bad.yml; echo $?→1.apm audit --ci --policy <valid-file> -f json | jq .policy→{"enforced": true, …}.apm audit --ciwith no--policyand no git remote →[!] … enforcement skipped,0(unchanged).Issue and approved scope
Issue: #3195
Human scope-approval comment: maintainer Daniel Meppiel (@danielmeppiel) (also the issue author) requested this fix directly and set its scope in the delivery session. The scope matches the triage advisory on #3195. A formal
apm-scope:v1record on the issue is still pending from the maintainer.Does this PR complete the issue, or what remains? Completes it, including the optional JSON/SARIF
policy.enforcedfield. Follow-up candidate (not in #3195's scope): auto-discoveredhash_mismatchinapm audit --cishould be unconditionally fail-closed, as the docs state.Fixes #3195
Type of change
Testing
Spec conformance (OpenAPM v0.1)
docs/src/content/docs/specs/openapm-v0.1.mdaddsreq-pl-021(§6.1), the §6.9 and §11.3.2 enumerations, the Appendix C row, and the 0.1.46 (proposed) history row.docs/public/specs/manifests/openapm-v0.1.requirements.yml.tests/spec_conformance/test_policy_reqs.py(req-pl-021, 4 cases).CONFORMANCE.{md,json}regenerated viauv run --extra dev python -m tests.spec_conformance.gen_statementand committed.Co-authored-by: Copilot 223556219+Copilot@users.noreply.github.com