After this chapter you can rely on the security checks APM runs on every install — and
say precisely where they stop. You will read the hidden-Unicode scan (a Critical
finding exits 1 and blocks deployment; a Warning-only finding exits 2 and
ships flagged), use the lockfile's content_hash as a tamper detector via
apm audit --ci, explain why a transitive MCP server is withheld — a
warning, not a build break, so the install still succeeds — and the two ways to opt one
in, and drive the opt-in Executable Trust Gate with apm approve /
apm deny. Above all you will hold the boundary this chapter exists to protect: APM is an
install-time gate, not a runtime sandbox. This is where
Provenance / security stops being a promise and
becomes a set of mechanical, always-on defaults.
Objective
Concept/Theory
A prompt is a program, so installation is the gate
Chapter 7 introduced apm audit as the on-demand integrity
tool — hidden-Unicode plus drift, explicitly not a CVE feed. This chapter reveals the
part that was implied but not stated: those same checks run automatically on every
apm install, before anything reaches disk. You do not have to remember to audit
to be safe by default. Understanding why the protection has to live at install time is the
whole concept.
Start from a claim APM makes about the thing it manages: “Agent context is executable — a prompt is a program for an LLM. APM treats it that way” (the three promises). An instruction file steers every generation; a prompt is a runnable procedure; a skill tells the agent how; and an MCP server grants it tool and data access. Installing an agent package therefore imports behavior the same way importing an npm package imports code — which makes both its provenance (where it came from) and its content (what the bytes actually say) security-relevant, not stylistic. This is the intuition Chapter 1 named: “a prompt is, in effect, a program for an LLM, so an unvetted prompt or transitive MCP server is real supply-chain surface.”
Now the timing. A traditional package manager has a gap you can inspect:
“Between npm install and npm start there is a gap — time for
npm audit, code review, and policy checks. Agent configuration has no such gap. The moment
a skill, instruction, or prompt file lands in .github/prompts/ or
.claude/agents/, any IDE agent watching the filesystem … may already be ingesting
it. There is no ‘execution step.’ File presence IS execution”
(Security model).
If there is no install→run gap, a post-install audit is too late — a watching
harness may have read the tainted file already. The only place a check can be preventive
rather than forensic is before the bytes reach an agent-readable directory. So APM
“treats package deployment as a pre-deployment gate: scan first, deploy only if clean”
(Security model).
That single idea justifies the whole chapter. An unvetted prompt is unreviewed code running against your codebase through an agent; an undeclared transitive MCP server is an unaudited capability grant — network, filesystem, tool calls — that arrived as a side effect of installing something else. Every mechanism below is a pre-deploy or on-demand-verify gate, never a runtime monitor. Hold that distinction from the first page:
In APM
Four checks, one chokepoint
“Secure by default” is not a slogan; it is four concrete checks that run at the install chokepoint. Three are always on; the fourth is an opt-in gate. APM's own summary of the always-on three: each install “scans for invisible Unicode that can hijack agent behavior, pins content hashes in the lockfile, and blocks transitive MCP servers unless they are explicitly declared or trusted” (the three promises).
| Check | Job | Always on? | Effect on install |
|---|---|---|---|
| Hidden-Unicode scan | catch invisible instructions in text | Yes | Critical blocks deploy (exit 1); Warning ships flagged |
| Content-hash verify | detect tampered bytes | Yes | fresh-download mismatch aborts; deployed drift surfaces in audit |
| Transitive-MCP withhold | no silent capability grants | Yes | MCP withheld, install succeeds (exit 0) |
| Executable Trust Gate | gate code-bearing primitives | No — opt-in | unapproved executables parked pending approval |
Scan: hidden Unicode can hide instructions in plain sight
LLMs tokenize characters a human never sees on screen, so invisible Unicode is a genuine prompt-injection surface. “Researchers have found hidden Unicode characters embedded in popular shared rules files. Tag characters (U+E0001–E007F) map 1:1 to invisible ASCII. Bidirectional overrides can reorder visible text. … The Glassworm campaign (2026) exploited this mechanism …. LLMs tokenize all of these individually, meaning models process instructions that developers cannot see on screen” (Security model). APM ranks findings by severity, and the severity maps to an exit code:
| Severity | Example codepoints | apm audit exit |
Blocks apm install deploy? |
|---|---|---|---|
| Critical | bidi overrides (U+202A–E, e.g. RLO U+202E), tag chars (U+E0001–E007F), Glassworm variation selectors (U+E0100–E01EF) |
1 |
Yes |
| Warning | zero-width (U+200B–D), soft hyphen (U+00AD), mid-file BOM |
2 (only if no Critical) |
No — ships flagged |
| Info | unusual whitespace, emoji variation selector (U+FE0F), ZWJ inside emoji |
0 (shown with -v) |
No |
You can point the scan at any file with apm audit --file — a downloaded
rules file, a PR diff, pasted instructions — which isolates the content scan from drift
detection. Here is a crafted “review checklist” with a zero-width space on line 4 and a
right-to-left override on line 7. The Critical finding decides the exit code:
apm audit --file on a file containing a Critical bidi override (U+202E) and a
Warning zero-width space (U+200B). The Critical finding drives the exit code. Runs
offline. apm v0.23.1
$ apm audit --file checkout-review.md
[>] Content Scan Findings
┏━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━┳━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━┓
┃ Severity ┃ File ┃ Location ┃ Codepoint ┃ Description ┃
┡━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━╇━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━┩
│ CRITICAL │ checkout-review.md │ 7:1 │ U+202E │ Right-to-left override │
│ WARNING │ checkout-review.md │ 4:1 │ U+200B │ Zero-width space │
└──────────┴────────────────────┴──────────┴───────────┴────────────────────────┘
[x] 1 critical finding(s) in 1 file(s) -- hidden characters detected
[i] These characters may embed invisible instructions
[i] Review file contents, then run 'apm audit --strip' to remove
# exit=1
A file whose only finding is a Warning — a lone zero-width space, no Critical — takes a lower severity and a different exit code:
2: flagged, not blocked. Runs
offline. apm v0.23.1
$ apm audit --file warn-only.md
[>] Content Scan Findings
WARNING warn-only.md 3:21 U+200B Zero-width space
[!] 1 warning(s) in 1 file(s) -- hidden characters detected
[i] Run 'apm audit --strip' to remove hidden characters
# exit=2
apm audit --strip removes both Critical and Warning characters in place (preserving emoji
and the ZWJ inside emoji sequences). Preview with --dry-run first — it writes
nothing — then apply, then re-audit clean:
--strip round-trip: dry-run preview (writes nothing), strip in place, re-audit clean.
All exit 0. Runs offline. apm v0.23.1
$ apm audit --file checkout-review.md --strip --dry-run
[>] Dry run -- the following would be removed by --strip:
checkout-review.md Critical: 1 Warning: 1 Total: 2
[i] 1 file(s) would be modified
[i] Run 'apm audit --strip' to apply
# exit=0 (file UNCHANGED -- U+202E still present)
$ apm audit --file checkout-review.md --strip
[+] Cleaned: checkout-review.md
[*] Cleaned 1 file(s)
# exit=0 (U+202E and U+200B both removed)
$ apm audit --file checkout-review.md
[*] 1 file(s) scanned -- no issues found
# exit=0
This scan already runs inside apm install (and apm compile,
apm unpack), before any integrator copies a file to .github/,
.claude/, or .cursor/. A Critical finding blocks the deploy
— the package stays cached under apm_modules/owner/package/ so you can inspect it, but
nothing reaches an agent-readable directory — and the install exits 1. Warnings
deploy with a flag. The escape hatch is explicit and loud: apm install --force
“bypass[es] the security scan's critical-finding block … Use only after independent
verification”
(apm install).
Two teaching points: --strip cleans deployed copies but does not touch the source
package, so the next install re-materializes the tainted bytes (a durable fix means pinning a clean
commit upstream); and the built-in gate is why you do not have to call apm audit to be
safe by default — the on-demand command is a power tool, not the protection itself.
Pin: the reproducibility hash doubles as a tamper detector
Chapter 6 introduced the lockfile's
content_hash (a SHA-256 fingerprint of a dependency's source tree) as a
reproducibility device: same bytes on every clone. The same field does a second job here.
“On subsequent installs, cached packages … are verified against the lockfile hash. When
the on-disk tree no longer matches, APM … re-downloads. If freshly downloaded content still does
not match the lockfile record, the install aborts (possible supply-chain tampering)”
(Security model).
On the deployed side, apm audit verifies each on-disk file — but with a two-tier
behavior you must get right. Tamper a deployed file and run a bare audit:
apm audit after a deployed instruction file was tampered: drift is reported but
advisory — exit 0. Runs offline.
apm v0.23.1
$ apm audit # after appending a line to a deployed instruction file
[>] Scanning all installed packages...
[>] Replaying install (cache-only)...
[!] Drift detected: 1 file(s)
[*] 3 file(s) scanned -- no issues found
modified (1):
- .github/instructions/meridian-checkout.instructions.md
[i] Run 'apm install' to re-sync deployed files with the lockfile.
# exit=0 <- drift alone is ADVISORY in a bare audit
Wire --ci and the same tampered file becomes a hard failure. The
content-integrity check compares each deployed file's SHA-256 against the lockfile record
and prints expected= vs actual=:
apm audit --ci on the same tampered file: the content-integrity check fails
with expected=/actual= hashes — exit 1. This is the tamper
gate. The reported check total — here 8 — varies by project (typically 8–9), so it may
differ from the count cited in Chapter 7. Runs offline.
apm v0.23.1
$ apm audit --ci
│ [x] │ content-integrity │ 1 file(s) with hash drift -- run 'apm install' to restore │
│ [x] │ drift │ drift detected: 1 file(s) │
content-integrity details:
- hash-drift: .github/instructions/meridian-checkout.instructions.md
(dep=<self>, expected=4acba6aa1bee..., actual=bab6d9cb4f14...)
[x] 2 of 8 check(s) failed
# exit=1
The same hash that guarantees byte-for-byte reproducibility catches tampering. Note the division of
labor with Chapter 6: apm install --frozen
is a structural guardrail (does the manifest match the lockfile before deploying?) and
explicitly not a content check — the CLI itself points you to apm audit for
on-disk integrity. Use --frozen to prove the lock is honored; use audit --ci to
prove the deployed bytes are intact. And one honest limit: a content hash detects change, not
intent. A legitimate upstream update and a malicious tamper look identical to the hash —
which is exactly why moving to new bytes is the deliberate, reviewed apm update
(Chapter 7), never a silent install-time acceptance.
Withhold: a transitive MCP server never auto-arrives
Trust for MCP servers is scoped by dependency depth. A server declared by a
direct dependency — a package you listed in your own apm.yml — is
auto-trusted, because you chose it. Install a package that declares its own MCP server directly and APM
configures it:
$ apm install # checkout-review-tools is a DIRECT dependency
[i] Trusting direct dependency MCP 'local-fetch' from 'checkout-review-tools'
| [+] local-fetch -> Copilot, Vscode (configured)
# exit=0 <- direct-dep MCP is auto-trusted
Now put that same server one level down — declared by a dependency of a dependency — and APM will not wire it up on your behalf. It withholds the server, tells you exactly how to opt in, and the install still succeeds:
[!] warning
— but the install succeeds (exit 0). Runs offline (local packages).
apm v0.23.1
$ apm install # checkout-review-pack -> checkout-review-tools (MCP is transitive)
[+] checkout-review-pack (local) |-- (files unchanged)
[+] checkout-review-tools (local) |-- (files unchanged)
[!] Transitive package 'checkout-review-tools' declares self-defined MCP
server 'local-fetch' (registry: false). Re-declare it in your apm.yml or
use --trust-transitive-mcp.
[*] Installed 2 APM dependencies in 0.8s.
# exit=0 <- the MCP is WITHHELD, but the install SUCCEEDS
The [!] message names two remedies, and they are not equivalent. Re-declaring the
server in your own apm.yml dependencies.mcp promotes it to a direct
dependency — a reviewed, committed, diffable trust decision the whole team sees. Passing
--trust-transitive-mcp is a blanket, per-install opt-in for all transitive
self-defined servers in that run, with no manifest record. Prefer re-declaration for anything shared;
reserve the flag for trusted, throwaway environments. The flag path:
--trust-transitive-mcp opts the withheld server in for this install. Runs offline.
apm v0.23.1
$ apm install --trust-transitive-mcp
[i] Trusting self-defined MCP server 'local-fetch' from transitive package
'checkout-review-tools' (--trust-transitive-mcp)
| [+] local-fetch -> Copilot, Vscode (configured)
# exit=0 <- now .vscode/mcp.json IS written
Gate: the opt-in Executable Trust Gate
MCP servers are not the only capability a dependency can ship. The Executable Trust Gate
(added in v0.22) governs the primitives that actually run code: hooks (.apm/hooks/),
bin/ executables, self-defined MCP servers, and canvas extensions. Text primitives —
skills, agents, instructions — are never gated, because they carry no code-execution risk. The gate
is opt-in: with no executables: block in your apm.yml (or a
non-empty org policy), it is disabled and executables deploy unconditionally — a backward-compatible
default. You can see the disabled state:
executables: block, the gate is disabled — executables deploy unconditionally.
Runs offline. apm v0.23.1
$ apm approve --list
[i] Executable-trust gate disabled -- all executables deploy. Add an
`executables:` block to apm.yml to enable it.
checkout-review-tools#0.3.0: mcp[+:gate-disabled]
Add an executables: block (even an empty {}) and the gate turns on. Now an
unapproved executable — here the very same self-defined MCP server, from a direct
dependency — is held behind an interactive prompt that defaults to No. Declining
parks the package; the install still succeeds:
[y/N] (default No). Declining parks
it — the install still succeeds (exit 0). Runs offline.
apm v0.23.1
$ apm install # after adding `executables: {}` to apm.yml
1 package(s) declare executable primitives:
checkout-review-tools#0.3.0 (direct dependency)
1 MCP server(s)
These will execute code on your machine when triggered by
your IDE or by 'apm run'.
Trust checkout-review-tools? [y/N]: n
[i] 1 package(s) left parked. Their executables will not run until trusted.
[i] Deploy later: apm approve checkout-review-tools (then re-run apm install)
# exit=0 <- declined = parked, not failed
Approve when you are ready. apm approve writes a committed
executables.allow entry to apm.yml — keyed on the package
name, not a path (see the pitfall in the next section):
apm approve records the trust decision in apm.yml; --list shows
the deciding layer flip from gate-disabled to project-allow. Runs offline.
apm v0.23.1
$ apm approve checkout-review-tools
Approved checkout-review-tools#0.3.0: 1 MCP server(s)
[i] Updated apm.yml executables block (1 approved).
# exit=0
$ apm approve --list
[i] 1 package(s) with executables (1 allowed type(s), 0 blocked type(s)).
checkout-review-tools#0.3.0: mcp[+:project-allow] # deciding layer: gate-disabled -> project-allow
apm approve writes to apm.yml.
apm v0.23.1
# apm.yml after `apm approve checkout-review-tools`
executables:
allow:
checkout-review-tools#0.3.0:
mcp: true
apm deny is the inverse (deny wins over allow), and apm policy explain <pkg>
prints which layer decided a package's fate
(apm approve). In
CI (non-interactive), unapproved executables are parked silently and the remedy is printed —
the install does not fail; only a required-but-untrusted executable hard-fails.
Two gates, kept separate
The subtlest point in this chapter: the MCP trust model and the Executable Trust Gate are two
independent mechanisms, and merging them will mislead you. They even overlap on one primitive
— a self-defined MCP server sits under both — which is exactly why the same
local-fetch server was auto-trusted as a direct dep earlier (the executable gate was off, so
only the depth-based MCP model applied) yet prompted for approval once the gate was enabled.
| MCP trust model | Executable Trust Gate (v0.22) | |
|---|---|---|
| On by default? | Always on | Opt-in (needs an executables: block or org policy) |
| Keyed on | dependency depth (direct vs transitive) | executable kind (from any dependency) |
| Covers | MCP servers | hooks, bin/, self-defined MCP, canvas extensions |
| Direct dependency | auto-trusted | gated (when enabled) |
| Transitive dependency | withheld (re-declare or --trust-transitive-mcp) |
gated (when enabled) |
| Manage with | --trust-transitive-mcp, re-declaration |
apm approve / apm deny / apm policy explain |
When to use / pitfalls
Getting the boundaries right
Every mechanism in this chapter is easy to over- or under-trust. Four corrections keep you honest, and a fifth reminder keeps the whole chapter in its lane.
With those in hand, the “when” is simple. Rely on the built-in install scan
for baseline safety on every install — you do not have to call apm audit to be
protected against Critical Unicode. Reach for apm audit on demand to scan an arbitrary file
(a downloaded rules file, a PR diff) or to check deployed integrity locally, and for
apm audit --ci as the branch-protection gate. Re-declare a transitive MCP server you have
reviewed and intend to keep; save --trust-transitive-mcp for throwaway environments. And
enable the Executable Trust Gate (an executables: block, or org policy in
Chapter 9) when you want approval — not just visibility
— before a dependency's code can run.
Worked example
Meridian reviews a transitive tool into the open
Back to the beat. Meridian's staff engineer adds checkout-review-pack at
v0.3.0. The pack is useful markdown, but it depends on checkout-review-tools,
which declares the self-defined MCP server local-fetch. On install, APM withholds that
transitive server — and, crucially, the install succeeds:
checkout-review-pack (v0.3.0). The transitive
local-fetch MCP server is withheld; the install succeeds (exit 0). The
private package fetch is SKIPPED-needs-network; the withhold behavior is the
offline-verified mechanism shown above. needs network
apm v0.23.1
$ apm install # SKIPPED-needs-network: private meridian-finance/checkout-review-pack
[+] meridian-finance/checkout-review-pack #v0.3.0 |-- (review prompt adopted)
[!] Transitive package 'checkout-review-tools' declares self-defined MCP
server 'local-fetch' (registry: false). Re-declare it in your apm.yml or
use --trust-transitive-mcp.
[*] Installed 2 APM dependencies.
# exit=0 <- the review pack installed; the transitive MCP server was withheld
SKIPPED-needs-network: the meridian-finance/checkout-review-pack
package is private, so cloning it needs a host token and is skipped for deterministic verification. The
[!] withhold message, the exit code, and the remedies are reproduced offline with a local
self-defined-MCP package (the “Withhold” figures above), so the mechanism Meridian relies on
is verified even though this exact fetch is not.
This is the moment Chapter 1's open question —
“which MCP servers are installed, and did each come from an approved source?” —
gets an answer. The capability is visible at install time instead of surfacing in a later audit.
The team runs a short security review of local-fetch, decides it is legitimate, and takes the
reviewable path rather than a blanket --trust-transitive-mcp: they re-declare
the server in their own apm.yml, promoting it to a direct dependency. That ships as
v0.3.1:
v0.3.1: the reviewed MCP server re-declared in dependencies.mcp, promoting it
to a committed direct dependency. The trust boundary is now a diff. This entry's shape mirrors the
verified lockfile mcp_configs record; the re-declaration itself was not exercised offline.
apm v0.23.1
# apm.yml -- Meridian checkout context, v0.3.1
dependencies:
apm:
- meridian-finance/checkout-review-pack#v0.3.1 # the review pack (private)
mcp:
# Re-declared after security review: promotes the transitive server to a
# reviewed, committed DIRECT dependency. The trust decision is now in the diff.
- name: local-fetch
registry: false
transport: stdio
command: npx
args:
- -y
- '@modelcontextprotocol/server-fetch'
Because local-fetch is now a direct dependency, the next install auto-trusts and
configures it — exactly the direct-dependency baseline verified earlier
([i] Trusting direct dependency MCP 'local-fetch'…). The difference from
--trust-transitive-mcp is the whole point: a teammate opening the v0.3.1 pull
request sees one added mcp: entry and knows precisely which capability was granted, by whom,
and after what review. The alternative ending is equally valid and worth naming: if the review had gone
the other way, the right move is simply to not grant the capability — drop the dependency,
and local-fetch stays withheld. Either way, an invisible transitive capability became an
explicit, auditable decision at install time.
Recap & next
Recap
- The trust boundary is install time. Agent context is executable — a prompt is a program for an LLM — and there is no install→run gap, so file presence is execution. APM's protection is therefore a pre-deploy gate: scan first, deploy only if clean.
-
Hidden-Unicode scanning, by severity. Critical (bidi overrides, tag chars, Glassworm
variation selectors) exits
1and blocks deploy; Warning-only exits2and ships flagged; clean/info exits0.apm audit --stripcleans deployed copies (not the source);apm install --forceis break-glass. -
The content hash catches tampering. Bare
apm auditreports drift as advisory (exit0);apm audit --ciis the tamper gate (exit1,content-integritywithexpected=/actual=). Distinct from Chapter 6's structural--frozen. -
Transitive MCP is withheld, not blocked. Direct-dep MCP is auto-trusted; a transitive
self-defined server is withheld with a
[!]warning while the install succeeds (exit0). Opt in by re-declaring it (reviewed, committed) or with--trust-transitive-mcp(blanket, per-install). -
Two orthogonal gates, one plane. The MCP trust model (depth-based, always on) and the
opt-in Executable Trust Gate (
apm approve/apm deny, kind-based) are separate mechanisms — and all of it is the install/integrity plane. APM is not a runtime sandbox; the harness governs what runs.
Next
These are the built-in, always-on defaults every install applies with no configuration. The natural next
question is organizational: which sources, scopes, and primitives are allowed here — and
how do you turn a withhold into a hard block, or a warning into policy?
Chapter 9 — Governance & Policy adds the configurable
layer on top of this chapter's defaults: an apm-policy.yml that enforces
Governance at install time, with tighten-only
inheritance and a warn→block rollout — still install-time, never runtime.