This doc covers how Mob handles the supply-chain risk introduced by
the plugin system (MOB_PLUGINS.md, MOB_STYLES.md). Plugins ship
native code that statically links into the host's process and runs
with the host's permissions. That's powerful — and same-process
trust requires a coherent vetting story.
The model has three layers:
- What Mob's build already prevents structurally (no runtime
plugin loading, no
dlopen, two-step opt-in). - What the framework adds explicitly (capability enforcement, manifest signing, audit tooling, source-hash pinning).
- What's left to the ecosystem (a curated allowlist, a concerns feed, community vetting).
Parts of this doc are still design. What ships today (mob_dev 0.7.6):
manifest signing with v2 envelopes, the trust store
(mix mob.plugin.trust), the build-time signature gate, and the
config :mob, :acknowledge_unsafe_plugins escape hatch for unsigned
plugins (see Manifest signing). The security modes
and per-plugin exemptions under "Development mode" are not implemented.
Implementation tasks track in plugin_extraction_plan.md Phase 2.
Threat model — what we're defending against
- Malicious plugin author. Publishes a useful-looking plugin that exfiltrates data, mines crypto, or escalates permissions. Same threat as a malicious npm package.
- Compromised legitimate plugin. A previously-trusted plugin's maintainer credentials or repo gets compromised; a new release smuggles in malicious code. The Solar Winds / event-stream model.
- Typosquatting.
mob_blutoothvsmob_bluetooth. User installs the wrong one. - Capability creep. Plugin starts as "color palette helper," later versions add network calls, file system writes, undeclared NIFs. The shape of what the plugin does drifts after the user trusted it.
- Transitive risk. Plugin A depends on plugin B; B is malicious but A's manifest looked fine.
Not in scope: defending against the host app itself being malicious (that's the OS's problem), or against the user explicitly granting permissions they shouldn't (that's a UX problem, not a security one).
Layer 1 — structural protections (already in place)
These come from the framework architecture, not the security layer proper. They're listed here so the security model knows what it doesn't have to re-solve.
No runtime plugin loading. Mob plugins are merged at compile
time. There is no Plugin.load(:url) API. The host's binary is the
sum of declared + activated plugins at build time.
No dlopen of NIFs. Mob's App Store / Play Store posture
requires statically linked NIFs. Plugin NIFs are physically embedded
in libpigeon.so (Android) or the iOS binary. We cannot execute
native code that wasn't compiled into the build. This eliminates a
huge class of post-install supply-chain attacks that JS plugin systems
face.
Manifest is data, not code. Plugin manifests are .exs files
evaluated by mob_dev at compile time in a constrained context — they
must reduce to a plain map. A plugin cannot use its manifest
evaluation to Code.eval arbitrary code into the host's build.
Two-step opt-in. mix deps.get installs but does not activate.
A silent dependency update cannot change the host's permissions,
gradle deps, or render tree. Activation requires explicit
config :mob, :plugins, [...] (or :styles). The diff is printed
at compile time so the user sees what merged.
Permission opt-in. A plugin's declared permissions (Android manifest entries, iOS plist keys) are merged only after the user activates the plugin. Same gate as iOS entitlements.
Layer 2 — framework-provided vetting
These are the additions the framework should provide to address the threat model.
Capability enforcement at compile time
The manifest declares what the plugin contributes (NIFs, permissions, iOS frameworks, Gradle deps). The compile step should refuse to merge anything not declared.
Concretely:
- A plugin that doesn't declare
:ios.frameworkscontaining"CoreBluetooth"cannot have its native code link againstCoreBluetoothsymbols. The linker fails at build time, not at runtime. - A plugin that doesn't declare
:android.permissionscontaining"android.permission.INTERNET"cannot have its Kotlin/C code that was discovered to reach for network resources slip through unnoticed. The linker / dexer fails. - A plugin that doesn't declare
:nifsat all cannot ship a.cfile that gets compiled intolibpigeon.so. The build only compiles sources listed in:nifs[*].native_dir.
The principle: the manifest is the entire contract. Anything the
plugin's source tries to do that isn't manifest-declared either fails
to link (preferred) or is flagged by mix mob.audit_plugins.
Manifest signing
Every plugin with a manifest is signed by its author with an Ed25519 key, and mob_dev refuses to build a host whose activated plugin has a missing, invalid or untrusted signature. The keys and tasks:
mix mob.plugin.keygenwrites the private key to~/.mob/keys/<name>.priv(mode 0600) and the public key to the plugin'spriv/mob_plugin.pub, which is committed and shipped.mix mob.plugin.signwritespriv/mob_plugin.sig.mix mob.plugin.trust <name>, run in the host, shows the plugin's fingerprint and declared capabilities (frameworks, permissions, Gradle dependencies, plist keys), asksy/N, and records the fingerprint inconfig :mob, :trusted_pluginsinmob.exs. Later releases signed by the same key pass; a different key signing the same plugin name is refused as a key rotation until the user trusts it again.
What the signature covers (v2 envelope). The envelope lists
{path, sha256} for every file the host's native build reads from the
plugin, and the signature covers that list. The set comes from one function
the build also uses (MobDev.Plugin.Sign.build_inputs/2): the manifest
itself, NIF sources and every file in their directories (C, Objective-C, Zig,
headers, cpp_archive sources), jni_source, Swift and Kotlin files,
resource files, migrations, fonts and images. The verifier rehashes every
listed file and checks the signature before it evaluates
priv/mob_plugin.exs, so a tampered manifest is never run. It then
recomputes the build inputs and refuses a signature that leaves any out
(MOB-297, mob_dev 0.7.3). Elixir code under lib/ is not a build input;
Hex's package checksum and mix.lock pin it. Rationale:
mob_dev decisions/2026-09-30-plugin-signature-coverage.md.
Legacy v1 envelopes are refused. A v1 signature covered the
evaluated manifest map, so checking it meant running the manifest first.
From mob_dev 0.7.6 every v1 envelope fails with
plugin :<name> ships a legacy v1 signature (MOB-301); the earlier
transition window for Hex-pinned first-party plugins is gone. Hosts move to a
v2-signed release with mix deps.update <name>; authors re-sign with
mix mob.plugin.sign and publish a new version.
Releases sign in CI. priv/mob_plugin.sig is gitignored, never
committed. The release workflow that mix mob.new_plugin generates (mob_dev
0.7.6+, the same pattern as the first-party plugins) reads the private key
from the MOB_PLUGIN_SIGN_KEY repository secret, refuses to publish if the
secret is empty or doesn't derive the committed priv/mob_plugin.pub, signs
the files it is about to publish, and runs mix hex.publish. The published
signature is therefore always made over exactly what ships. See the
Writing a Plugin guide.
This catches threat 2 (compromised maintainer credentials republishing under same name): without the signing key, a republished package fails verification, and a new key is a visible key-rotation prompt.
Source-hash pinning
mix.lock already pins package versions. Extend to pin a sha256 of
the plugin's priv/native/ tree, so a Hex package republished with
the same version number but altered native code (a Hex registry
compromise, or a malicious overwrite) is detected by mix.
mix mob.audit_plugins
A new task that scans every activated plugin's Elixir + native source for patterns Mob considers risky:
- Code-injection vectors:
Code.eval_string,Code.eval_file,:erlang.binary_to_term/2with the safe-mode bit clear, dynamic module-name construction inapply/3. - Undeclared FFI access: any NIF call that doesn't appear in
the manifest's
:nifslist, any iOS framework reference outside the declared:ios.frameworks. - Undeclared I/O: file system access outside the app sandbox,
network calls when
:permissionsdoesn't includeINTERNET, process spawning (System.cmd,Port.open). - Anti-tamper sniffing: code that checks for sandboxing, debugger attachment, or unusual env vars — patterns more common in malware than legitimate plugins.
The task produces a report with per-finding severity. Some findings
are advisory (a legitimate plugin might legitimately use :os.cmd);
others should block activation by default and require explicit
opt-in. The default ruleset is conservative; the host app can declare
exemptions:
# mob.exs
config :mob, :plugin_audit, [
exemptions: %{
mob_chat_kit: [:network_calls], # known to use HTTP for API
mob_bluetooth: [:undeclared_ffi] # legitimate Core Bluetooth use
}
]Exemptions are visible in the build output — no silent allow-listing.
Vetting status in mix mob.plugins
Annotate each installed plugin with its current vetting state:
$ mix mob.plugins
mob_bluetooth 0.3.1 activated signed (key 9a3c…) audit ✓
mob_chat_kit 1.0.0 activated signed (key f1e8…) audit ⚠ exemptions
mob_demo_xyz 0.1.0 installed unsigned audit ✗ blocking
States:
- unsigned — plugin has no signature. A native build refuses it,
path dependency or not, unless it is listed in
config :mob, :acknowledge_unsafe_plugins(which prints a banner on every build). - signed — the v2 signature verifies. Key fingerprint shown.
- trusted — plugin's key has been explicitly trusted via
mix mob.plugin.trust. Subsequent updates with same key pass silently. - audit ✓ / ⚠ / ✗ — outcome of
mix mob.audit_plugins. ✗ blocks activation by default.
Development mode — author your own without fighting the framework
Status: design, not implemented.
config :mob, :plugin_securityandconfig :mob, :unsafe_pluginsdon't exist. Today an unsigned plugin you're developing either gets signed locally (mix mob.plugin.keygenonce, thenmix mob.plugin.signafter each change to a build input) or is listed inconfig :mob, :acknowledge_unsafe_plugins.
Security that gets in the way of plugin authors is security that gets globally disabled. The framework provides explicit modes so iteration on your own (or a forked) plugin is friction-free, and the production path is loud but never blocked.
The three modes
# mob.exs
# Default — production-grade. All activated plugins must be signed
# and pass the audit. Path deps and git refs require per-plugin
# exemptions below.
config :mob, :plugin_security, :strict
# Permissive — same checks run; findings warn instead of block.
# For evaluating new plugins before committing trust.
config :mob, :plugin_security, :permissive
# Dev — path deps and git refs accepted unsigned. Audit still
# reports but never blocks. Prints a per-build banner so the state
# is never forgotten.
config :mob, :plugin_security, :devPer-plugin escape hatches, available in any mode
config :mob, :unsafe_plugins, [
{:my_wip_plugin, allow: [:unsigned]},
{:friend_fork_of_thing, allow: [:unsigned, :git_ref]},
{:experimental_thing, allow: [:undeclared_network]}
]Per-plugin is the more honest interface: you list which packages get
which exemptions and why (the inline comment is the "why"). Global
:dev is a convenience for the case where everything is local.
:unsafe_plugins works in any security mode — it's how you say "yes,
I know this one specific plugin is unsigned, I'm fine with that, here's
why in a comment." Reviewers see the list in code review.
Git refs
A plugin pulled via {:plugin_x, git: "github.com/y/z", ref: "..."}
is treated as unsigned by default. Git refs are accepted in :dev
mode without further configuration; in :strict / :permissive they
need allow: [:git_ref] in :unsafe_plugins. This catches the
typosquat-by-fork pattern (yourorg/popular-plugin vs
y0urorg/popular-plugin) — you can still use the fork, you just have
to acknowledge it explicitly.
Building a release with unverified plugins
This is the core philosophical point. You can do it. The framework cannot tell the difference between "developer shipping their own hand-written plugin" and "developer shipping an unvetted third-party plugin." Both are open-source — you're allowed to ship either.
What the framework does instead is bang gongs loudly:
A persistent banner on every build (debug AND release) listing every plugin that's unsigned, unaudited, or git-ref'd. The banner does not go away until those plugins are signed or removed.
Release builds add a one-time acknowledgement requirement. The first time you build a release with unverified plugins, mob_dev prints the banner and refuses to proceed. To proceed, add to
mob.exs:# I have personally reviewed the unverified plugins listed above. # They are either my own code, a fork I maintain, or a third-party # plugin I've read end-to-end. I accept responsibility for any # security implications. config :mob, :acknowledge_unsafe_plugins, trueThat config line lives in committed source. Reviewers see it.
mix mob.audit_pluginscalls it out. The acknowledgement doesn't make the banner stop — it just unblocks the build.Acknowledgement is global, not per-plugin. Adding it means "yes, I've reviewed all of these." Re-adding a new unverified plugin doesn't auto-extend the acknowledgement; the build refuses again until the user re-acknowledges, which forces them to re-read the list.
mix mob.audit_pluginscontinues to print findings. Even acknowledged, even in dev mode, the audit task runs and reports. The user can read the findings; the framework doesn't suppress them.
The framework treats this like a seatbelt. We tell you you should wear one. We make it really clear when you're not. We don't lock the ignition.
Why not a CLI flag?
Because CLI flags vanish after the build. mix release --i-know-best
is invisible after the fact — a reviewer reading the repo can't tell
the release was built with reduced trust. Committed config is the
durable record: the codebase itself shows the decision.
Why not a hard block in prod?
Two reasons:
- Self-hosted plugins are legitimate. A developer authoring their own plugin to extract a feature out of core has a perfectly valid reason to ship a release with an "unsigned" (unpublished) plugin. Refusing this is paternalistic.
- Forks are legitimate. A developer fixing a bug in a third-party plugin and shipping a release from their fork is doing the right thing — that's how open source moves forward. Refusing this blocks the patch path.
We're trying to be the home of the hackers. Hackers know what they're doing; they just need to be reminded loudly when they're stepping outside the well-lit path.
What the prod-build banner looks like
=========================================================================
[mob] Plugin trust report for release build
=========================================================================
Signed + audited:
mob_bluetooth 0.3.1 trusted key 9a3c…
mob_camera 0.2.0 trusted key f1e8…
Unverified — proceeding because :acknowledge_unsafe_plugins is set:
my_wip_plugin path:plugins/my_wip_plugin (unsigned)
friend_fork_of_thing git:github.com/x/y#branch (unsigned, git_ref)
These plugins ship with the release. Their behavior is your
responsibility. See MOB_PLUGIN_SECURITY.md for the trust model.
=========================================================================The banner is unavoidable. It appears in every build's output, in CI logs, in the developer's terminal. Anyone who looks at the build log sees exactly what shipped and on what trust basis. Loud and visible is the substitute for restrictive.
Layer 3 — ecosystem
What the framework can't unilaterally provide; needs community infrastructure.
Curated allowlist
The Mob project (or a community maintainer) curates a list of
"mob-vetted" plugins — plugins that have been read, the author known,
the manifest reviewed. Lives at https://mob.dev/plugins-vetted.json
(or wherever) and is fetched by mix mob.doctor once a week,
cached locally.
mix mob.plugins shows the vetted status:
mob_bluetooth 0.3.1 activated signed audit ✓ vetted (2026-03)
mob_random_xyz 0.1.0 activated signed audit ✓ not vettedNot vetted ≠ bad. It just means nobody from the Mob project has reviewed it. Users decide what threshold matters.
Concerns feed
A separate feed at https://mob.dev/plugin-concerns.json reporting:
- Known CVEs in specific plugin versions
- Maintainer ownership changes
- Plugins removed for malicious behavior
- Recommended upgrade paths
mix mob.doctor and mix mob.audit_plugins both consult this
feed and surface concerns in their output. Same model as npm audit
- the GitHub advisory database.
Reputation signals (downstream of curation)
For each plugin, mix mob.plugins --verbose can show:
- Hex download count (last 30 days)
- Time since last release
- Number of open issues / mean time to close
- Whether the package's GitHub repo is archived
- Maintainer's other published packages
These don't decide trust on their own — they're context. Pair with the curated list for the actual call.
Putting the layers together
A user activating a new plugin walks through:
Add
mob_bluetoothtodepsand toconfig :mob, :pluginsinmob.exs, runmix deps.get, thenmix mob.plugin.trust mob_bluetooth.- Hex resolves + downloads the package.
- Mob verifies the manifest signature (Layer 2).
- Mob checks the audit ruleset (Layer 2).
- Mob checks the curated allowlist (Layer 3).
- Mob checks the concerns feed (Layer 3).
Mob prints a one-screen summary. Today
mix mob.plugin.trustprints the fingerprint and declared capabilities; the author, audit, vetted and concerns lines are design:mob_bluetooth 0.3.1 Author: alice@example.com (key 9a3c…12ef) Capabilities: BLUETOOTH_CONNECT, BLUETOOTH_SCAN, CoreBluetooth Audit: ✓ no findings Vetted: yes (reviewed 2026-03-12) Concerns: none Activate? [y/N]User confirms; mob_dev merges contributions, prints the resulting permission diff to
AndroidManifest.xmlandInfo.plist.
A plugin failing any layer can still be activated — but the user has
to add the per-plugin entry to :unsafe_plugins (see the
"Development mode" section above) so the decision is visible in
committed code.
Phasing the implementation
Per plugin_extraction_plan.md Phase 2:
- First (blocks Phase 3): capability enforcement at compile
time, manifest signing format,
mix mob.audit_pluginswith the default ruleset. These are framework-internal and have to be stable before real extractions ship. - Second (parallel with Phase 3): the curated allowlist infrastructure, concerns feed, reputation signals. Can iterate.
- Third (post-extractions, ongoing): trust-key rotation policy, plugin author guides ("how to publish a vetted plugin"), periodic re-audit of vetted plugins.
The order matters because waves of extraction shouldn't happen until the signed manifest format and the audit ruleset are stable — otherwise we're shipping plugins without the chain of custody we want end users to rely on.
What we explicitly don't promise
- Not a sandbox. Plugins run in-process with full BEAM/native access. We can't isolate them at runtime the way browser extensions are isolated. The protection is compile-time (no surprises in the binary), not runtime.
- Not a gatekept registry. Anyone can publish a
mob_*Hex package. The curated allowlist is opt-in trust, not gatekept entry. - Not protection against the developer themselves. Releasing with unverified plugins is allowed — it's open source, you're allowed to ship your own code. The framework makes the situation visible, not impossible. The seatbelt model: we tell you you should wear one, we make it really clear when you're not, we don't lock the ignition. See "Development mode" above for the mechanics.
The goal is "informed consent at activation time, structural prevention of post-install drift, persistent visible warnings when the user steps outside the well-lit path" — not unbreakable sandboxing, not paternalistic refusal to build.
This framework is meant for hackers. Hackers are smart enough to read the warnings and decide for themselves.