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).
Objective
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 (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:
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 installresolves 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 runningapm installlocally — they cannot accidentally deploy a denied package even without CI. There is no--policyflag onapm install; discovery is automatic from the git remote. -
The CI audit gate.
apm audit --ci --policy orgruns 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:
| 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:
| 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:
- Author in
warnin<org>/.github/apm-policy.yml. Nothing breaks; every violation is reported and every install still succeeds. - Read the telemetry — the warnings in
apm installoutput and, canonically, the SARIF thatapm audit --ci --policy orgfeeds into Code Scanning — to build a fleet-wide violation list. - 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.
- Flip to
blockonce 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 viaextends:so a strict child escalates for its scope only — there is no per-ruleenforcementknob.
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:
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-leveltargets:is silently ignored (like any unknown key).allow: null= no opinion,[]= allow nothing, a list = only these;denywins. -
Two enforcement points, one rule set. The install preflight (auto-discovered from the
git remote — no
--policyflag) and the CI gateapm audit --ci --policy org. Transitive MCP is re-checked againstmcp.*; themcp.trust_transitivefield parses but does not enforce — the real gate is--trust-transitive-mcp(Chapter 8). -
The dial is
enforcement. Same violation:warnshows it and exits0(measure);blockfails it and exits1(gate). Roll out by measurement —warn→ read SARIF → remediate →blocknarrowly — never by flipping a switch on day one. -
Inheritance is tighten-only. Enterprise → org → repo:
allows intersect, denies union,
enforcementescalates. A repo can only make policy stricter; exceptions are granted upward, at the parent. Local-fileextends:does not merge — real inheritance needs a remote parent. -
The engine is early preview at apm v0.23.1.
apm policyhas onlystatusandexplain; 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.