Skip to content
Merged
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
17 changes: 9 additions & 8 deletions docs/src/content/docs/enterprise/apm-policy.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -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`:

```
<org>/
.github/
.github-private/
apm-policy.yml # auto-discovered by every repo in <org>
```

Expand Down Expand Up @@ -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 `<your-org>/.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 `<your-org>/.github-private/apm-policy.yml` (or `<your-org>/.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"
Expand All @@ -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
Expand Down
28 changes: 14 additions & 14 deletions docs/src/content/docs/enterprise/governance-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 (`<org>/.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 (`<org>/.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).

Expand All @@ -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 `<org>/.github/apm-policy.yml`, auto-discovered from the project's git remote.
- `apm-policy.yml` -- what is allowed. Lives at `<org>/.github-private/apm-policy.yml` (or `<org>/.github/apm-policy.yml`), auto-discovered from the project's git remote.

Four enforcement points read those files:

Expand All @@ -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 <scope>]` (the only enforcer of the audit-only checks).

The trust boundary is your `<org>/.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 `<org>/.github-private` (or `<org>/.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.

Expand Down Expand Up @@ -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 `<org>/.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 `<org>/.github-private` (or `<org>/.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.

Expand Down Expand Up @@ -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 `<org>/.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 (`<org>/.github-private/apm-policy.yml` or `<org>/.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 `<org>/.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.

Expand All @@ -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 `<org>/.github/apm-policy.yml`** -- restrict to a security team. Every change requires their review.
- **Branch protection on `<org>/.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 `<org>/.github-private/apm-policy.yml`** (or `<org>/.github/apm-policy.yml`) -- restrict to a security team. Every change requires their review.
- **Branch protection on `<org>/.github-private` main** (or `<org>/.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 `<org>/.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 `<org>/.github-private` (or `<org>/.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:

Expand Down Expand Up @@ -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 `<org>/.github/apm-policy.yml`:
**Minimal policy (1-10 repos)** -- one file at `<org>/.github-private/apm-policy.yml` (or `<org>/.github/apm-policy.yml`):

```yaml
name: "Starter policy"
Expand Down Expand Up @@ -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:
Expand All @@ -601,7 +601,7 @@ policy:
## 16. Where to next

:::tip[Starting a pilot?]
**15-minute path:** copy `templates/apm-policy-starter.yml` to `<your-org>/.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 `<your-org>/.github-private/apm-policy.yml` (or `<your-org>/.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.
Expand Down
4 changes: 2 additions & 2 deletions docs/src/content/docs/enterprise/policy-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down Expand Up @@ -943,7 +943,7 @@ All violation messages above flow through `InstallLogger.policy_violation`; unde

Checklist to publish a policy:

1. Create `<org>/.github/apm-policy.yml` in the org's `.github` repository.
1. Create `<org>/.github-private/apm-policy.yml` (or `<org>/.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.
Expand Down
4 changes: 2 additions & 2 deletions packages/apm-guide/.apm/skills/apm-usage/governance.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
18 changes: 15 additions & 3 deletions src/apm_cli/policy/discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading