Chapter 8

Security by Default

Understand and rely on APM's install-time security checks without confusing them with runtime sandboxing.

Objective

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.

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).

The four install-time checks, each mapped to the concept it implements and what it does to your install. Verified on apm v0.23.1.
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:

Hidden-Unicode severity and the apm audit exit code it produces. Verified on apm v0.23.1.
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:

A Warning-only file (zero-width space, no Critical) exits 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:

The --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:

A bare 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:

A direct dependency's self-defined MCP server is auto-trusted — you chose the package. Runs offline (local package). apm v0.23.1
$ 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:

A transitive self-defined MCP server is withheld with a [!] 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:

With no 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:

With the gate enabled, an unapproved executable is gated [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
The committed, shareable trust decision 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.

The two orthogonal trust gates. The MCP trust model is depth-based and always on; the Executable Trust Gate is kind-based and opt-in. Verified on apm v0.23.1.
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:

Meridian adds 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 1 and blocks deploy; Warning-only exits 2 and ships flagged; clean/info exits 0. apm audit --strip cleans deployed copies (not the source); apm install --force is break-glass.
  • The content hash catches tampering. Bare apm audit reports drift as advisory (exit 0); apm audit --ci is the tamper gate (exit 1, content-integrity with expected=/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 (exit 0). 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.