diff --git a/docs/src/content/docs/enterprise/apm-policy.md b/docs/src/content/docs/enterprise/apm-policy.md index a2cb91c29..f6ddf1a09 100644 --- a/docs/src/content/docs/enterprise/apm-policy.md +++ b/docs/src/content/docs/enterprise/apm-policy.md @@ -44,9 +44,10 @@ APM discovers your org-level policy by checking candidate repos in this order -- | Priority | Repo name | Valid on | |----------|-----------|---------| -| 1 | `.github` | GitHub and GitHub API-compatible hosts | -| 2 | `.apm` | GitHub and GitHub API-compatible hosts | -| 3 | `_apm` | GitHub API-compatible hosts and Azure DevOps | +| 1 | `.github-private` | GitHub and GitHub API-compatible hosts | +| 2 | `.github` | GitHub and GitHub API-compatible hosts | +| 3 | `.apm` | GitHub and GitHub API-compatible hosts | +| 4 | `_apm` | GitHub API-compatible hosts and Azure DevOps | Azure DevOps does not allow repository names starting or ending with a period, so only `_apm` is tried on ADO hosts. ADO requires repositories to live inside projects; the convention uses `_apm` for both the project and repo: @@ -57,11 +58,11 @@ Azure DevOps does not allow repository names starting or ending with a period, s apm-policy.yml ``` -On GitHub and GitHub API-compatible hosts, the `.github` convention remains the recommended default: +On GitHub and GitHub API-compatible hosts, the `.github-private` repo is preferred because policy files typically contain internal governance rules that should not be public. If `.github-private` does not exist, APM falls back to `.github`: ``` / - .github/ + .github-private/ apm-policy.yml # auto-discovered by every repo in ``` @@ -183,11 +184,11 @@ For lockfile-based forensic recipes, see the [Governance Guide §13: enforcement ## Get started in 20 minutes -This is nothing-to-working-policy. By the end you have a file in your org's `.github` repo that blocks one bad package across every repo. +This is nothing-to-working-policy. By the end you have a file in your org's `.github-private` repo (or `.github` if you prefer public policy) that blocks one bad package across every repo. ### 1. Write the minimal policy -Copy this into `/.github/apm-policy.yml`. It blocks one package family and starts in `warn` so you do not break anyone on day one: +Copy this into `/.github-private/apm-policy.yml` (or `/.github/apm-policy.yml`). It blocks one package family and starts in `warn` so you do not break anyone on day one: ```yaml name: "Acme baseline policy" @@ -203,7 +204,7 @@ Commit and push. On the next `apm install` in any repo in the org, a user who de ### 2. Preview before you commit -Point at a policy explicitly to test it before it lands in `.github`: +Point at a policy explicitly to test it before it lands in your policy repo (`.github-private` or `.github`): ```bash apm policy status --policy-source ./apm-policy.yml diff --git a/docs/src/content/docs/enterprise/governance-guide.md b/docs/src/content/docs/enterprise/governance-guide.md index 7af3a3037..42fa7b7c5 100644 --- a/docs/src/content/docs/enterprise/governance-guide.md +++ b/docs/src/content/docs/enterprise/governance-guide.md @@ -19,7 +19,7 @@ This guide is the spec for that scale. It tells you, with code-level honesty, ex You own the trust boundary and need defensible answers when an auditor asks "what was running, and who allowed it?" -APM gives you a git-tracked record of every agent dependency deployed (`apm.lock.yaml`) and a policy file your security team controls (`/.github/apm-policy.yml`). The forensic answer to "what was active during the incident?" is one `git log` command. The trust boundary is your `.github` repo's branch protection. +APM gives you a git-tracked record of every agent dependency deployed (`apm.lock.yaml`) and a policy file your security team controls (`/.github-private/apm-policy.yml`). The forensic answer to "what was active during the incident?" is one `git log` command. The trust boundary is your `.github-private` (or `.github`) repo's branch protection. Most relevant: section 7 (bypass contract), section 8 (install gate guarantees), section 12 (auditing the auditor), section 13 (enforcement audit log), section 14 (known gaps). @@ -46,7 +46,7 @@ Most relevant: section 5 (enforcement points), section 6 (composition), section Two files do all the work: - `apm.lock.yaml` -- what was deployed. Pinned to exact commit SHAs, git-tracked, regenerated by every `apm install`. -- `apm-policy.yml` -- what is allowed. Lives at `/.github/apm-policy.yml`, auto-discovered from the project's git remote. +- `apm-policy.yml` -- what is allowed. Lives at `/.github-private/apm-policy.yml` (or `/.github/apm-policy.yml`), auto-discovered from the project's git remote. Four enforcement points read those files: @@ -55,7 +55,7 @@ Four enforcement points read those files: 3. The `apm install` transitive-MCP preflight (a second pass after APM packages resolve their own MCP dependencies). 4. `apm audit --ci [--policy ]` (the only enforcer of the audit-only checks). -The trust boundary is your `/.github` repository. CODEOWNERS and branch protection on that repo are what make the policy authoritative. Section 12 covers how to lock that down. +The trust boundary is your `/.github-private` (or `/.github`) repository. CODEOWNERS and branch protection on that repo are what make the policy authoritative. Section 12 covers how to lock that down. `apm compile` and `apm run` enforce zero policy. They trust the artifacts that `apm install` placed on disk. APM is an install-time gate, not a runtime sandbox. @@ -393,7 +393,7 @@ graph TD style P4 fill:#fce4ec,stroke:#c2185b,stroke-width:3px,color:#000 ``` -**Phase 1 -- ship a warn-mode policy.** Land `apm-policy.yml` in `/.github` with `enforcement: warn`. Nothing breaks anywhere. Every `apm install` in the org now discovers the policy, runs the checks, and logs `[!]` warnings for violations -- but proceeds. +**Phase 1 -- ship a warn-mode policy.** Land `apm-policy.yml` in `/.github-private` (or `/.github`) with `enforcement: warn`. Nothing breaks anywhere. Every `apm install` in the org now discovers the policy, runs the checks, and logs `[!]` warnings for violations -- but proceeds. **Phase 2 -- wire audit into CI.** Use a shared GitHub Actions template (or composite action) that runs `apm audit --ci --policy org -f sarif` and uploads the SARIF to GitHub Code Scanning. Violations become visible to repo owners as code-scanning alerts. Be honest with stakeholders here: in `warn` mode, audit rewrites violations to `passed=True` so the exit code stays 0. CI does not fail. The visibility is in the SARIF + Code Scanning UI, not in the green/red check. Branch protection cannot enforce yet. @@ -423,9 +423,9 @@ For richer customization (matrix builds, monorepo splits, vendored policy paths) `apm audit --ci` in warn mode rewrites violations to `passed=True` (audit.py:589-598), so the audit command exits 0 even on policy violations. Visibility is in the SARIF upload + Code Scanning UI, not in branch-protection status. To gate merges on policy violations, you must run in `block` mode. ::: -**Phase 3 -- triage and clean up.** Repo owners either fix their `apm.yml` to comply with the policy, or they open a PR to `/.github/apm-policy.yml` to add an explicit allow entry with rationale. The PR flow is the policy change-management trail (see section 12). +**Phase 3 -- triage and clean up.** Repo owners either fix their `apm.yml` to comply with the policy, or they open a PR to the org policy repo (`/.github-private/apm-policy.yml` or `/.github/apm-policy.yml`) to add an explicit allow entry with rationale. The PR flow is the policy change-management trail (see section 12). -**Phase 4 -- flip to block.** Once Code Scanning shows the violation backlog is drained, change `enforcement: block` in `/.github/apm-policy.yml`. Stage by team if the org is large: a team can adopt block early by setting `enforcement: block` in its own team-level intermediate policy, leaving the org policy at `warn`. (Tighten-only merge means the team's `block` wins for repos under that team's `extends:` chain.) Use `apm policy status --check` in CI as a pre-flight that explains the effective policy and surfaces what would be blocked, before the gate phase actually blocks it. +**Phase 4 -- flip to block.** Once Code Scanning shows the violation backlog is drained, change `enforcement: block` in the org policy repo. Stage by team if the org is large: a team can adopt block early by setting `enforcement: block` in its own team-level intermediate policy, leaving the org policy at `warn`. (Tighten-only merge means the team's `block` wins for repos under that team's `extends:` chain.) Use `apm policy status --check` in CI as a pre-flight that explains the effective policy and surfaces what would be blocked, before the gate phase actually blocks it. **Circuit-breaker rollout for large fleets.** For 100+ repos, do not flip block org-wide in one commit. Stage: enable `block` for 10% of repos for 1 week (via team-level extends), monitor SARIF alert volume and on-call pages, expand to 50% for 1 week, then 100%. If SARIF volume spikes or on-call escalations cluster, revert to `warn` at the org level (one commit) while you triage. @@ -437,13 +437,13 @@ For step-by-step CI YAML and SARIF upload examples beyond the snippet above, see The org policy file is the trust root. Protecting it is on you, not on APM. -- **CODEOWNERS on `/.github/apm-policy.yml`** -- restrict to a security team. Every change requires their review. -- **Branch protection on `/.github` main** -- required reviewers, no force push, no direct push to main, dismiss stale approvals on new commits. -- **GitHub Ruleset on the org `.github` repo** (recommended) -- requires approval from a specific team for any change to policy files. See [GitHub Rulesets](../integrations/github-rulesets/). +- **CODEOWNERS on `/.github-private/apm-policy.yml`** (or `/.github/apm-policy.yml`) -- restrict to a security team. Every change requires their review. +- **Branch protection on `/.github-private` main** (or `/.github`) -- required reviewers, no force push, no direct push to main, dismiss stale approvals on new commits. +- **GitHub Ruleset on the org `.github-private` repo** (recommended) -- requires approval from a specific team for any change to policy files. See [GitHub Rulesets](../integrations/github-rulesets/). - **Change history is `git log apm-policy.yml`.** Rationale lives in commit messages and PR descriptions. Make commit-message rationale a CODEOWNERS-checked review item. - **Policy change cooling period** (recommended) -- every change to `apm-policy.yml` requires a PR with rationale and a 24-72 hour waiting period before merge. This is a process control, not a code control, but it is the single most important thing you can add. -**Separation of duties for SOX / SOD-sensitive environments.** CODEOWNERS for `apm-policy.yml` should require approvals from a team distinct from the team authoring the change. Configure GitHub Rulesets on the `/.github` repo to require reviewers from `@org/policy-approvers`, where that team is disjoint from `@org/policy-authors`. The same author cannot self-approve, and the approval team has no commit rights to the policy file directly. +**Separation of duties for SOX / SOD-sensitive environments.** CODEOWNERS for `apm-policy.yml` should require approvals from a team distinct from the team authoring the change. Configure GitHub Rulesets on the `/.github-private` (or `/.github`) repo to require reviewers from `@org/policy-approvers`, where that team is disjoint from `@org/policy-authors`. The same author cannot self-approve, and the approval team has no commit rights to the policy file directly. **Lint for bypass flags in CI workflows.** Add a pre-merge check that fails any PR introducing a policy-bypass flag without an explicit security review label: @@ -541,7 +541,7 @@ graph TD style HubYaml fill:#fce4ec,stroke:#c2185b,stroke-width:1px,color:#000 ``` -**Minimal policy (1-10 repos)** -- one file at `/.github/apm-policy.yml`: +**Minimal policy (1-10 repos)** -- one file at `/.github-private/apm-policy.yml` (or `/.github/apm-policy.yml`): ```yaml name: "Starter policy" @@ -577,14 +577,14 @@ compilation: **Hub + org + repo (100+ repos)** -- enterprise hub with broad allows, org extending and tightening, repos pinning the hash: ```yaml -# enterprise-hub-org/.github/apm-policy.yml +# enterprise-hub-org/.github-private/apm-policy.yml name: "Enterprise baseline" version: "1.0.0" enforcement: warn dependencies: allow: ["microsoft/*", "contoso/*", "partner-corp/*"] -# contoso/.github/apm-policy.yml +# contoso/.github-private/apm-policy.yml extends: "enterprise-hub-org" enforcement: block dependencies: @@ -601,7 +601,7 @@ policy: ## 16. Where to next :::tip[Starting a pilot?] -**15-minute path:** copy `templates/apm-policy-starter.yml` to `/.github/apm-policy.yml`, wire the CI YAML from section 11 Phase 2, ship in warn mode. Flip to block once SARIF is clean. +**15-minute path:** copy `templates/apm-policy-starter.yml` to `/.github-private/apm-policy.yml` (or `/.github/apm-policy.yml`), wire the CI YAML from section 11 Phase 2, ship in warn mode. Flip to block once SARIF is clean. ::: - [`apm-policy.yml`](./apm-policy/) -- the file's mental model. diff --git a/docs/src/content/docs/enterprise/policy-reference.md b/docs/src/content/docs/enterprise/policy-reference.md index 5ddbd5fc1..bc5ce0c6d 100644 --- a/docs/src/content/docs/enterprise/policy-reference.md +++ b/docs/src/content/docs/enterprise/policy-reference.md @@ -100,7 +100,7 @@ Inherit from a parent policy. See [Inheritance](#inheritance). | Value | Source | |-------|--------| -| `org` | Parent org's `.github/apm-policy.yml` | +| `org` | Parent org's `.github-private/apm-policy.yml` (falls back to `.github`) | | `owner/repo` | Cross-org policy from a specific repository | | `https://...` | Direct URL to a policy file | @@ -943,7 +943,7 @@ All violation messages above flow through `InstallLogger.policy_violation`; unde Checklist to publish a policy: -1. Create `/.github/apm-policy.yml` in the org's `.github` repository. +1. Create `/.github-private/apm-policy.yml` (or `/.github/apm-policy.yml`) in the org's policy repository. 2. Start from the [Standard org policy](#standard-org-policy) example above and trim it to the minimum that reflects your governance posture. 3. Set `enforcement: warn` first. Let CI surface diagnostics across consuming repos for one cycle without breaking installs. 4. When the warn-cycle is clean, switch to `enforcement: block`. Communicate the change in your org's CHANGELOG/announcements channel — `apm install` will start failing for any non-compliant repo. diff --git a/packages/apm-guide/.apm/skills/apm-usage/governance.md b/packages/apm-guide/.apm/skills/apm-usage/governance.md index d3d895770..f59f61297 100644 --- a/packages/apm-guide/.apm/skills/apm-usage/governance.md +++ b/packages/apm-guide/.apm/skills/apm-usage/governance.md @@ -384,7 +384,7 @@ may use. This section covers how that contract is enforced at `apm install` time ### 2. Discovery and applicability APM auto-discovers org policy from the project's git remote by checking -`.github`, `.apm`, and `_apm` policy repos in order on GitHub API-compatible +`.github-private`, `.github`, `.apm`, and `_apm` policy repos in order on GitHub API-compatible hosts. Azure DevOps hosts use `_apm` only, because ADO rejects dot-prefixed repository names. Repositories with no detectable git remote (unpacked bundles, temp dirs) emit an explicit "could not determine org" line and skip discovery. @@ -629,7 +629,7 @@ as `[x]` errors and exit `1`. Checklist to publish a policy: -1. Create `apm-policy.yml` in the org policy repo (`.github` on GitHub, `_apm` +1. Create `apm-policy.yml` in the org policy repo (`.github-private` or `.github` on GitHub, `_apm` project/repo on Azure DevOps). 2. Start from the recommended starter below and trim to the minimum reflecting your governance posture. diff --git a/src/apm_cli/policy/discovery.py b/src/apm_cli/policy/discovery.py index c88d42e7d..44934dd72 100644 --- a/src/apm_cli/policy/discovery.py +++ b/src/apm_cli/policy/discovery.py @@ -3,13 +3,14 @@ Discovery flow: 1. Extract org from git remote (github.com/contoso/my-project -> "contoso") 2. Determine host profile (default or ado) to select candidate repos -3. Try candidate repos in precedence order (.github > .apm > _apm) +3. Try candidate repos in precedence order (.github-private > .github > .apm > _apm) 4. Fetch apm-policy.yml via GitHub Contents API or ADO Items API 5. Resolve inheritance chain via resolve_policy_chain 6. Cache the **merged effective policy** with chain metadata 7. Parse and return ApmPolicy Candidate repo precedence: +- .github-private -- private org-wide config (preferred; skipped on ADO) - .github -- GitHub convention (skipped on ADO) - .apm -- cross-platform convention (skipped on ADO) - _apm -- universal fallback (valid on every git host) @@ -68,7 +69,7 @@ # Candidate repo names in precedence order (first valid policy wins). # Host profiles select which candidates are valid for a given git host. -_DEFAULT_POLICY_REPOS: tuple[str, ...] = (".github", ".apm", "_apm") +_DEFAULT_POLICY_REPOS: tuple[str, ...] = (".github-private", ".github", ".apm", "_apm") _ADO_POLICY_REPOS: tuple[str, ...] = ("_apm",) # ADO project name for the policy repo (ADO requires a project container). @@ -655,7 +656,7 @@ def _auto_discover( 1. Run git remote get-url origin 2. Parse org + host from URL 3. Select host profile to determine candidate repos - 4. Try each candidate in precedence order (.github > .apm > _apm) + 4. Try each candidate in precedence order (.github-private > .github > .apm > _apm) - 404/absent -> continue to next candidate - Error (auth, timeout, malformed) -> fail-closed immediately - Found -> return (first match wins) @@ -699,6 +700,17 @@ def _auto_discover( candidate_repo, host, ) + # Warn when .github-private is absent on a GitHub host -- GitHub + # returns 404 (not 403) when the token lacks private-repo scope, + # so an absent result may mask a token-permission problem. + if candidate_repo == ".github-private" and _is_github_host(host): + logger.warning( + "Policy repo %s/%s not found (or token lacks private-repo " + "read scope). If your org publishes policy in .github-private, " + "ensure the token has Contents:read permission on that repo.", + org, + candidate_repo, + ) continue # Any other outcome (found, error, malformed, etc.) -> return immediately diff --git a/tests/unit/policy/test_discovery.py b/tests/unit/policy/test_discovery.py index c3d4af22c..af095036a 100644 --- a/tests/unit/policy/test_discovery.py +++ b/tests/unit/policy/test_discovery.py @@ -617,7 +617,7 @@ def test_none_auto_discovers(self, mock_run, mock_fetch): with tempfile.TemporaryDirectory() as tmpdir: result = discover_policy(Path(tmpdir), no_cache=True) self.assertTrue(result.found) - self.assertEqual(result.source, "org:contoso/.github") + self.assertEqual(result.source, "org:contoso/.github-private") @patch("apm_cli.policy.discovery.subprocess.run") def test_no_git_remote(self, mock_run): @@ -635,16 +635,19 @@ def test_cache_hit_returns_cached(self, mock_run, mock_fetch): returncode=0, stdout="https://github.com/contoso/my-project.git\n", ) + # .github-private will 404, falling through to .github which has a cache hit + mock_fetch.return_value = (None, "404: Policy file not found") with tempfile.TemporaryDirectory() as tmpdir: root = Path(tmpdir) - # Pre-populate cache + # Pre-populate cache for .github _write_cache("contoso/.github", _make_test_policy(), root) result = discover_policy(root, no_cache=False) self.assertTrue(result.found) self.assertTrue(result.cached) - mock_fetch.assert_not_called() + # .github-private is fetched (no cache), .github is served from cache + self.assertEqual(mock_fetch.call_count, 1) @patch("apm_cli.policy.discovery._fetch_github_contents") @patch("apm_cli.policy.discovery.subprocess.run") @@ -658,7 +661,7 @@ def test_ghe_repo_ref_includes_host(self, mock_run, mock_fetch): with tempfile.TemporaryDirectory() as tmpdir: result = discover_policy(Path(tmpdir), no_cache=True) self.assertTrue(result.found) - self.assertEqual(result.source, "org:ghe.example.com/contoso/.github") + self.assertEqual(result.source, "org:ghe.example.com/contoso/.github-private") class TestAutoDiscover(unittest.TestCase): @@ -667,25 +670,26 @@ class TestAutoDiscover(unittest.TestCase): @patch("apm_cli.policy.discovery._fetch_from_repo") @patch("apm_cli.policy.discovery._extract_org_from_git_remote") def test_github_com_first_candidate_found(self, mock_extract, mock_fetch): - """When .github has a policy, it wins immediately.""" + """When .github-private has a policy, it wins immediately.""" mock_extract.return_value = ("contoso", "github.com") mock_fetch.return_value = PolicyFetchResult( - policy=ApmPolicy(), source="org:contoso/.github", outcome="found" + policy=ApmPolicy(), source="org:contoso/.github-private", outcome="found" ) with tempfile.TemporaryDirectory() as tmpdir: result = _auto_discover(Path(tmpdir), no_cache=True) - # First call should be for .github + # First call should be for .github-private first_call = mock_fetch.call_args_list[0] - self.assertEqual(first_call[0][0], "contoso/.github") + self.assertEqual(first_call[0][0], "contoso/.github-private") self.assertTrue(result.found) @patch("apm_cli.policy.discovery._fetch_from_repo") @patch("apm_cli.policy.discovery._extract_org_from_git_remote") def test_github_com_cascades_to_dot_apm(self, mock_extract, mock_fetch): - """.github absent -> falls back to .apm.""" + """.github-private and .github absent -> falls back to .apm.""" mock_extract.return_value = ("contoso", "github.com") mock_fetch.side_effect = [ + PolicyFetchResult(outcome="absent"), # .github-private 404 PolicyFetchResult(outcome="absent"), # .github 404 PolicyFetchResult( policy=ApmPolicy(), source="org:contoso/.apm", outcome="found" @@ -694,9 +698,10 @@ def test_github_com_cascades_to_dot_apm(self, mock_extract, mock_fetch): with tempfile.TemporaryDirectory() as tmpdir: result = _auto_discover(Path(tmpdir), no_cache=True) - self.assertEqual(mock_fetch.call_count, 2) - self.assertEqual(mock_fetch.call_args_list[0][0][0], "contoso/.github") - self.assertEqual(mock_fetch.call_args_list[1][0][0], "contoso/.apm") + self.assertEqual(mock_fetch.call_count, 3) + self.assertEqual(mock_fetch.call_args_list[0][0][0], "contoso/.github-private") + self.assertEqual(mock_fetch.call_args_list[1][0][0], "contoso/.github") + self.assertEqual(mock_fetch.call_args_list[2][0][0], "contoso/.apm") self.assertTrue(result.found) @patch("apm_cli.policy.discovery._fetch_from_repo") @@ -705,6 +710,7 @@ def test_github_com_cascades_to_underscore_apm(self, mock_extract, mock_fetch): """All dot-prefixed repos absent -> falls back to _apm.""" mock_extract.return_value = ("contoso", "github.com") mock_fetch.side_effect = [ + PolicyFetchResult(outcome="absent"), # .github-private 404 PolicyFetchResult(outcome="absent"), # .github 404 PolicyFetchResult(outcome="absent"), # .apm 404 PolicyFetchResult( @@ -714,7 +720,7 @@ def test_github_com_cascades_to_underscore_apm(self, mock_extract, mock_fetch): with tempfile.TemporaryDirectory() as tmpdir: result = _auto_discover(Path(tmpdir), no_cache=True) - self.assertEqual(mock_fetch.call_count, 3) + self.assertEqual(mock_fetch.call_count, 4) self.assertTrue(result.found) @patch("apm_cli.policy.discovery._fetch_from_repo") @@ -726,7 +732,7 @@ def test_github_com_all_absent(self, mock_extract, mock_fetch): with tempfile.TemporaryDirectory() as tmpdir: result = _auto_discover(Path(tmpdir), no_cache=True) - self.assertEqual(mock_fetch.call_count, 3) + self.assertEqual(mock_fetch.call_count, 4) self.assertEqual(result.outcome, "absent") self.assertFalse(result.found) @@ -743,21 +749,76 @@ def test_github_com_error_fail_closed(self, mock_extract, mock_fetch): result = _auto_discover(Path(tmpdir), no_cache=True) # Only one call -- error stops the cascade self.assertEqual(mock_fetch.call_count, 1) + self.assertEqual(mock_fetch.call_args_list[0][0][0], "contoso/.github-private") self.assertFalse(result.found) self.assertIn("401", result.error) + @patch("apm_cli.policy.discovery._fetch_from_repo") + @patch("apm_cli.policy.discovery._extract_org_from_git_remote") + def test_403_is_error_not_absent(self, mock_extract, mock_fetch): + """HTTP 403 -> fail-closed (cache_miss_fetch_fail), not absent.""" + mock_extract.return_value = ("contoso", "github.com") + mock_fetch.return_value = PolicyFetchResult( + error="403: Access denied to contoso/.github-private", + outcome="cache_miss_fetch_fail", + ) + + with tempfile.TemporaryDirectory() as tmpdir: + result = _auto_discover(Path(tmpdir), no_cache=True) + self.assertEqual(mock_fetch.call_count, 1) + self.assertEqual(result.outcome, "cache_miss_fetch_fail") + self.assertNotEqual(result.outcome, "absent") + self.assertFalse(result.found) + + @patch("apm_cli.policy.discovery._fetch_from_repo") + @patch("apm_cli.policy.discovery._extract_org_from_git_remote") + def test_github_private_auth_error_does_not_fall_through(self, mock_extract, mock_fetch): + """.github-private auth error -> fail-closed, .github NOT tried.""" + mock_extract.return_value = ("contoso", "github.com") + mock_fetch.side_effect = [ + PolicyFetchResult(error="403: Access denied", outcome="cache_miss_fetch_fail"), + PolicyFetchResult(policy=ApmPolicy(), outcome="found"), + ] + + with tempfile.TemporaryDirectory() as tmpdir: + result = _auto_discover(Path(tmpdir), no_cache=True) + # Only .github-private tried -- error stops cascade + self.assertEqual(mock_fetch.call_count, 1) + self.assertFalse(result.found) + + @patch("apm_cli.policy.discovery._fetch_from_repo") + @patch("apm_cli.policy.discovery._extract_org_from_git_remote") + def test_github_private_absent_falls_back_to_github(self, mock_extract, mock_fetch): + """.github-private absent (404) -> cascade to .github.""" + mock_extract.return_value = ("contoso", "github.com") + mock_fetch.side_effect = [ + PolicyFetchResult(outcome="absent"), # .github-private 404 + PolicyFetchResult( + policy=ApmPolicy(), source="org:contoso/.github", outcome="found" + ), # .github found + ] + + with tempfile.TemporaryDirectory() as tmpdir: + result = _auto_discover(Path(tmpdir), no_cache=True) + self.assertEqual(mock_fetch.call_count, 2) + self.assertEqual(mock_fetch.call_args_list[0][0][0], "contoso/.github-private") + self.assertEqual(mock_fetch.call_args_list[1][0][0], "contoso/.github") + self.assertTrue(result.found) + @patch("apm_cli.policy.discovery._fetch_from_repo") @patch("apm_cli.policy.discovery._extract_org_from_git_remote") def test_ghe_repo_ref_includes_host(self, mock_extract, mock_fetch): mock_extract.return_value = ("contoso", "ghe.example.com") mock_fetch.return_value = PolicyFetchResult( - policy=ApmPolicy(), source="org:ghe.example.com/contoso/.github", outcome="found" + policy=ApmPolicy(), + source="org:ghe.example.com/contoso/.github-private", + outcome="found", ) with tempfile.TemporaryDirectory() as tmpdir: _auto_discover(Path(tmpdir), no_cache=True) first_call = mock_fetch.call_args_list[0] - self.assertEqual(first_call[0][0], "ghe.example.com/contoso/.github") + self.assertEqual(first_call[0][0], "ghe.example.com/contoso/.github-private") @patch("apm_cli.policy.discovery._extract_org_from_git_remote") def test_no_remote_returns_error(self, mock_extract): @@ -803,11 +864,11 @@ class TestPolicyRepoCandidates(unittest.TestCase): def test_github_com_returns_all_candidates(self): result = _policy_repo_candidates("github.com") - self.assertEqual(result, (".github", ".apm", "_apm")) + self.assertEqual(result, (".github-private", ".github", ".apm", "_apm")) def test_ghe_returns_all_candidates(self): result = _policy_repo_candidates("ghe.example.com") - self.assertEqual(result, (".github", ".apm", "_apm")) + self.assertEqual(result, (".github-private", ".github", ".apm", "_apm")) def test_ado_dev_azure_com(self): result = _policy_repo_candidates("dev.azure.com") @@ -823,7 +884,7 @@ def test_ado_visualstudio_com(self): def test_unknown_host_returns_all(self): result = _policy_repo_candidates("gitlab.example.com") - self.assertEqual(result, (".github", ".apm", "_apm")) + self.assertEqual(result, (".github-private", ".github", ".apm", "_apm")) class TestFetchAdoContents(unittest.TestCase):