Chapter 9

Governance & Policy

Write and pilot an apm-policy.yml that enforces agent-package rules at install time.

Objective

After this chapter you can author and pilot an apm-policy.yml — the organization's allow/deny contract for agent packages — and enforce it at install time. You will keep Governance (“is this allowed here?”) crisply separate from the Provenance / security checks of Chapter 8 (“is this safe?”), write the verified policy schema (including the one field the running example got wrong — target rules live under compilation.target.allow, not a top-level targets: key), and drive the warn → block rollout that turns a rule on without breaking every repo on day one. You will run apm audit --ci --policy and read its two exit codes, inspect the effective policy with apm policy status, and reason about tighten-only enterprise → org → repo inheritance. One honesty note up front: at apm v0.23.1 the policy engine is early preview — schema, discovery, and inheritance ship today, but enforcement semantics may shift between minor versions, so pin the CLI before you lean on it as a production gate (Policy reference).

Concept/Theory

Two questions at the same gate

Chapter 8 made every install safe by construction: hidden-Unicode scanning, content-hash pinning, and transitive-MCP blocking all fire before any file reaches disk. Those checks ship with the CLI and answer a question with a universal answer — a bidi-override character is dangerous everywhere; a tampered hash is invalid everywhere. But a second question has no universal answer: may our developers install some-org/some-skill? is the GitHub MCP server approved? which compilation targets do we support? That depends entirely on your organization's risk posture, contracts, and compliance regime.

That second question is Governance — APM's third promise, “governed by policy” (the three promises). Where security is intrinsic (built into the tool, non-negotiable), governance is declared: an org authors an allow/deny contract in apm-policy.yml and APM enforces it at the same pre-disk gate as the security checks. Same seam, two different authorities. Crucially, policy “does not scan code semantics or behave like an antivirus. It enforces declarations against an allow/deny list before APM writes any file” (Policy files).

Security and governance answer different questions at the same install-time gate. Verified against apm v0.23.1.
  Security (Chapter 8) Governance (this chapter)
Question Is this artifact safe by construction? Is this artifact allowed here?
Source of authority Intrinsic — ships in the CLI Declared — the org authors apm-policy.yml
Varies by org? No — same everywhere Yes — each org's own contract
Where enforced Install gate (pre-disk) Install gate and apm audit --ci

The two verdicts are independent, and both must pass. A package can be perfectly safe (clean scan, valid hash) yet denied because it comes from an un-vetted source; an allowed source still runs through the security scan. So beware the twin misconception — “governance is just more security,” and its inverse, “if it's allowed by policy it must be safe.” Neither holds. Policy is not an antivirus, and an allow-list is not a safety certificate.

Policy is deliberately visible to developers yet authoritative for the org. It fires on a developer's own local apm install and prints an inline violation with a remediation hint — but its source of truth is not the developer's checkout. As Chapter 1 foreshadowed, apm-policy.yml is an org-remote artifact: it lives in your org's policy repo (protected by CODEOWNERS and branch protection) and is auto-discovered from your project's git remote, not committed as a per-repo default (Policy files). The single distinction to protect above all others is the one the microsoft/apm README draws: apm-policy.yml governs what gets installed; your agent harness governs what runs. The two planes do not overlap. Governance is an install-time gate, not a runtime sandbox — the schema has no fields for runtime permissions or agent behavior.

In APM

The policy file: apm-policy.yml

A policy is a single YAML file whose top-level keys each govern one facet of what may be installed — dependency sources, MCP servers and transports, compilation targets, manifest shape, unmanaged files. There are no runtime keys. Here is the verified schema, trimmed to the sections you will actually reach for:

The apm-policy.yml schema, verified against apm v0.23.1. The single top-level enforcement dial governs every rule; target rules live under compilation.target.allow. apm v0.23.1
name: Example Org Policy
enforcement: warn            # off | warn | block  -- the single top-level dial

dependencies:
  allow:                     # null = no opinion | [] = allow NOTHING | [globs] = only these
    - microsoft/**
  deny:                      # deny always beats allow
    - sketchy-org/**
  require: []                # packages every repo must declare (supports "#version" pins)
  require_pinned_constraint: true   # ban unbounded refs (bare branch / wildcard / open range)

mcp:
  allow: null
  deny: []
  transport:
    allow: [http, stdio]     # subset of: stdio | sse | http | streamable-http
  self_defined: warn         # allow | warn | deny  (inline MCPs declared in apm.yml)

compilation:                 # target rules live HERE -- NOT a top-level `targets:` key
  target:
    allow: [copilot, claude, cursor]

manifest:
  required_fields: [name, version, description]
  scripts: allow             # allow | deny

unmanaged_files:
  action: warn               # ignore | warn | deny

The confirmed, enforced top-level keys at v0.23.1 are: name, version, extends, enforcement, fetch_failure, cache, dependencies, mcp, compilation, manifest, unmanaged_files, registry_source, security, and executables. One allow-list rule is worth memorizing because it is easy to get backwards: a list left as null means “no opinion” (allow anything not denied); an empty list [] means “allow nothing”; a populated list means “only these.” And deny is always evaluated first — it beats allow.

One gate, two enforcement points

The same policy file is evaluated at two places that share one rule set (Policy files):

  • The install-time preflight gate. apm install resolves the dependency tree, runs the policy gate against the resolved set, then writes files. A blocking violation halts the install with a non-zero exit and nothing reaches disk. This protects a developer running apm install locally — they cannot accidentally deploy a denied package even without CI. There is no --policy flag on apm install; discovery is automatic from the git remote.
  • The CI audit gate. apm audit --ci --policy org runs the same policy checks plus the baseline lockfile/integrity checks from Chapter 7, and emits SARIF for GitHub Code Scanning. This is the check you wire into branch protection — the authoritative gate for merges, and the only enforcer of the audit-only rules (required fields, scripts, unmanaged files) that the install gate does not check.

This is where the Chapter 8 thread ties back: when an APM package pulls in its own MCP dependencies, those transitive servers are resolved and then passed through a second policy pass, so no transitive MCP server reaches your runtime config without clearing the same mcp.allow / mcp.deny / mcp.transport rules as a direct one. Do not confuse that with the mcp.trust_transitive policy field — at v0.23.1 that field parses but is not enforced; the actual transitive-MCP gate is the --trust-transitive-mcp CLI flag (default deny) you met in Chapter 8 (Policy files).

The enforcement dial: warn and block

The top-level enforcement field has three modes — off, warn, block — and it is a single, uniform dial: every rule runs under the same mode. (Only two sub-fields carry their own allow|warn|deny sense — mcp.self_defined and unmanaged_files.action — but their pass/fail still surfaces through the top-level dial.) The difference between the two modes you will use is entirely in the exit code on the same violation, verified on real violating projects:

The enforcement dial, same violation, two modes. Run with apm audit --ci --policy. Verified on apm v0.23.1.
Mode Every check runs? Violation shown as Summary Exit code
warn Yes [+](enforcement: warn) All N check(s) passed 0 — measures, does not gate
block Yes failing row 1 of N check(s) failed 1 — fails closed

That single-column difference — exit 0 vs exit 1 on an identical finding — is the whole mechanism behind the rollout you will run in the worked example.

Inspecting policy: apm policy status and explain

At v0.23.1, apm policy has exactly two subcommands (there is no check, diagnose, or show). apm policy status reports the effective posture — discovery outcome, cache, and the merged rule counts — and accepts --policy-source (org | owner/repo | https://… | a local path) so you can preview a file before it lands. It always exits 0 unless you add --check, which turns an unreachable or misconfigured org policy into a non-zero CI pre-flight. The other subcommand, apm policy explain <package>, is the executable-trust surface from Chapter 8 (the apm approve/deny decision) — not the apm-policy.yml gate, so it is not re-taught here.

Tighten-only inheritance: enterprise → org → repo

Policies compose along a chain — canonically an enterprise hub → org policy → repo override — declared with extends: (max depth 5; cross-host extends: is refused as a credential-leak mitigation). The merge is tighten-only: “children can only tighten rules, never relax them… a repo can be more restrictive than the org, but cannot widen what the org has allowed” (Policy files). Because the org policy is discovered from the git remote, the org level is the default authority; a lower level is understood as a stricter child, never a replacement. In plain English:

How the tighten-only merge composes each field kind. The effective policy is always at least as strict as every level above it. From the policy reference; verified rule shapes at apm v0.23.1.
Field kind Merge rule What a child can do
allow lists (dependencies.allow, mcp.allow, compilation.target.allow…) intersection Narrow the list — never widen it
deny lists (dependencies.deny, mcp.deny) union Add denies — never remove one
enforcement max (off < warn < block) Escalate warn → block — never the reverse
require_pinned_constraint logical OR Turn it on — never off if a parent set it
max_depth, cache.ttl min Lower the cap — never raise it

The consequence a security lead cares about: a repo cannot override the org policy to unblock itself. A child that tries to drop block back to warn, or re-allow a denied source, is rejected. Exceptions therefore flow upward, not downward — to exempt a repo you relax the rule at the parent level (narrow a deny glob, or add the package to dependencies.allow), documented in the policy file itself. APM has no first-class waiver field; that file is your audit log. Discovery reinforces the org-remote model from Chapter 1: APM resolves the org from the project's git remote and searches candidate repos in order — .github, then .apm, then _apm — first found wins, cached about an hour. Rolling that baseline across product groups is the fleet story of Chapter 11.

When to use / pitfalls

Roll out by measurement, not by switch

A new policy rule is retroactive: the next apm install or CI audit in every consuming repo runs it against dependencies that were legal yesterday. Flip straight to enforcement: block and every repo that already has a violation fails on its next run — you spend the day rolling back instead of rolling out (Policy pilot). So warn is not a lesser mode you skip — it is the measurement phase that makes block safe. The recommended sequence is four steps:

  1. Author in warn in <org>/.github/apm-policy.yml. Nothing breaks; every violation is reported and every install still succeeds.
  2. Read the telemetry — the warnings in apm install output and, canonically, the SARIF that apm audit --ci --policy org feeds into Code Scanning — to build a fleet-wide violation list.
  3. Remediate the top offenders — upgrade or replace the offending dependency (the default fix), or grant an exception by relaxing the rule at the parent level.
  4. Flip to block once the count is zero (or the survivors are documented exceptions). To block one rule while others still warn, stage rules (clean one, then flip the file) or split via extends: so a strict child escalates for its scope only — there is no per-rule enforcement knob.

Two smaller habits round out the “when to use”: wire apm audit --ci --policy org (not bare apm audit) into branch protection — it is the authoritative gate — and reach for apm policy status --check only when you want CI to fail because the org policy is unreachable or misconfigured, since plain apm policy status never fails. Use apm audit --ci --policy to gate on rule violations; use --check to gate on policy reachability. The bypass contract is honest but loud: apm install --no-policy and APM_POLICY_DISABLE=1 skip the auto-discovered org policy for a single, logged invocation — but an explicit --policy <source> overrides the bypass and still enforces, and the baseline lockfile checks are never bypassable.

Worked example

Here is the pilot policy the team ships. Note the corrected target rule under compilation.target.allow — the one field an earlier draft got wrong. Its require: rule names meridian-finance/meridian-standards, the org's shared standards package that Meridian actually authors and publishes in Chapter 10 — it is foreshadowed here at the pilot's v0.1.0, and the enforcement build later bumps that pin to the released v1.0.0:

backend/examples/ch09/apm-policy.warn.yml — Meridian's pilot policy in warn. Target rules use compilation.target.allow, not a top-level targets:. apm v0.23.1
name: Meridian Checkout APM Policy
version: "0.1.0"
enforcement: warn                       # measurement phase -- reports, never gates

dependencies:
  allow:                                # only these sources (deny still wins)
    - microsoft/**
    - github/awesome-copilot/**
    - meridian-finance/**
  deny:
    - sketchy-org/**
  require:
    - meridian-finance/meridian-standards#v0.1.0   # resolving this pkg: SKIPPED-needs-network
  require_pinned_constraint: true       # ban bare-branch / wildcard / open-range refs

mcp:
  allow:
    - io.github.github/github-mcp-server            # resolving this MCP: SKIPPED-needs-network
  deny:
    - io.github.sketchy/sketchy-mcp
  transport:
    allow: [http, stdio]                # blocks sse + streamable-http
  self_defined: warn                    # allow | warn | deny

compilation:                            # CORRECTED -- was a top-level `targets:` (a silent no-op)
  target:
    allow: [copilot, claude, cursor]

manifest:
  required_fields: [name, version, description]
  scripts: allow

unmanaged_files:
  action: warn

Before trusting a policy, confirm it parses and registers its rules. This is the check that would have caught the targets: bug — it runs entirely offline against the local file:

apm policy status confirms the pilot parses and the corrected target rule registered (compilation_targets_allowed: 3; the wrong top-level targets: reports -1). Runs offline. apm v0.23.1
$ apm policy status --policy-source ./apm-policy.warn.yml -o json
{
  "outcome": "found",
  "enforcement": "warn",
  "rule_counts": {
    "dependencies_allow": 3,
    "dependencies_deny": 1,
    "compilation_targets_allowed": 3        # corrected key registered 3 targets (was -1)
    # ... other rule_counts omitted
  },
  "extends_chain": []
}                                            # exit 0

The pilot policy carries many rules at once, which makes any single exit code hard to read in isolation. To watch the warn → block dial cleanly, take Meridian's highest-risk rule — require_pinned_constraint — on its own. The meridian-checkout repo already carries the transitive review skill on a bare #main branch from Chapter 7, so that rule has something to catch. The committed apm-policy.min-warn.yml is exactly that one rule in warn:

apm audit --ci --policy ./apm-policy.min-warn.yml — the pin rule reports the unbounded ref but the audit still passes. Replays from cache and scans locally, so it runs offline. apm v0.23.1
$ apm audit --ci --policy ./apm-policy.min-warn.yml
  [>] Replaying install (cache-only)... [+] No drift detected
  ...                                   # baseline + policy checks run
  │ [+] │ dependency-pinned-constraint │ 1 dependency(ies) use unbounded constraints (hint: pin to a semver range, literal tag, or SHA) (enforcement: warn) │

  [*] All 18 check(s) passed          # exit 0 -- warn MEASURES; it does not fail the build

After two sprints of watching that finding (and others) in Code Scanning and remediating the top offenders, the team flips the highest-risk rules to fail closed. In the full pilot that means three tightenings from the warn file — the top-level dial to block, mcp.self_defined to deny, and unmanaged_files.action to deny:

The three enforcement tightenings Meridian made in backend/examples/ch09/apm-policy.block.yml when moving from pilot to enforcement — these are the point of the move. (The file also bumps its own version and the required-standards pin from the pilot's 0.1.0 to 1.0.0.) apm v0.23.1
enforcement: block                      # warn -> block (the whole file now fails closed)

mcp:
  self_defined: deny                    # warn -> deny

unmanaged_files:
  action: deny                          # warn -> deny

Now the identical pin violation — run through the same offline audit, using the committed apm-policy.min-block.yml to isolate the one rule — fails the build. Same finding, one different exit code — with a bonus: because block now fails, it also surfaces the per-dependency detail line that warn only tallied. Warn measures the count; block hands you the actionable per-dependency detail right before it gates:

apm audit --ci --policy ./apm-policy.min-block.yml — the same finding now gates. The only change from the previous run is enforcement: block. Runs offline. apm v0.23.1
$ apm audit --ci --policy ./apm-policy.min-block.yml
  [>] Replaying install (cache-only)... [+] No drift detected
  ...                                   # baseline + policy checks run
  │     │ dependency-pinned-constraint │ 1 dependency(ies) use unbounded constraints (hint: pin to a semver range, literal tag, or SHA) │
  │     │                              │   - microsoft/apm-sample-package: bare branch 'main' tracks a moving tip │

  [x] 1 of 18 check(s) failed         # exit 1 -- block FAILS CLOSED

That exit 0 → exit 1 on an unchanged violation is the entire point of the dial. In production the team would fix the unbounded ref before flipping (pin the #main skill to a tag, exactly as Chapter 6 taught) — the demo holds the violation constant only to isolate the mode change. Two parts of this scenario are real but not exercised offline and are marked needs network: resolving the private meridian-finance/meridian-standards package and the registry io.github.github/github-mcp-server MCP (the rules for both parse and enforce; only the specific remote artifacts are not fetched), and the org-remote discovery + tighten-only inheritance merge that will apply once this file is published to <org>/.github/apm-policy.yml and a stricter child extends: it.

Recap & next

Recap

  • Security vs. governance are two questions at one gate. Security asks “is it safe?” (intrinsic, universal); Governance asks “is it allowed here?” (declared in apm-policy.yml, per-org). Both fire pre-disk; both must pass; allowed ≠ safe. Policy governs what installs, never what runs.
  • Write the verified schema. Target rules live under compilation.target.allow — a top-level targets: is silently ignored (like any unknown key). allow: null = no opinion, [] = allow nothing, a list = only these; deny wins.
  • Two enforcement points, one rule set. The install preflight (auto-discovered from the git remote — no --policy flag) and the CI gate apm audit --ci --policy org. Transitive MCP is re-checked against mcp.*; the mcp.trust_transitive field parses but does not enforce — the real gate is --trust-transitive-mcp (Chapter 8).
  • The dial is enforcement. Same violation: warn shows it and exits 0 (measure); block fails it and exits 1 (gate). Roll out by measurement — warn → read SARIF → remediate → block narrowly — never by flipping a switch on day one.
  • Inheritance is tighten-only. Enterprise → org → repo: allows intersect, denies union, enforcement escalates. A repo can only make policy stricter; exceptions are granted upward, at the parent. Local-file extends: does not merge — real inheritance needs a remote parent.
  • The engine is early preview at apm v0.23.1. apm policy has only status and explain; pin the CLI before relying on policy as a production gate.

Next

You have now consumed, locked, maintained, secured, and governed agent context — the full four-property arc for a consumer. Chapter 10 — Becoming a Producer flips the perspective: packaging and publishing your own reusable skills, prompts, and plugins so other teams install them through the same loop — and shipping them shaped to pass the very Governance gate you just wrote.