Static checks
App Motoko code is compiled into the same actor as the kernel. The static policy is one enforcement layer that blocks the reviewed privileged Motoko surface; generated wrappers, capability injection, and broker checks supply the runtime layers. The denylist is not a formal proof of confinement for every future Motoko or compiler feature.
One policy, two paths
neutron-security defines one ordered rule set, projected over two
compilers:
| Path | When | Effect |
|---|---|---|
| Packaging | Every final module of a package being built | Findings print the offending source and throw |
| Compilation | Every reachable module before assembly | Findings hard-fail the compile |
Both paths use the same ordered policy vocabulary. Packaging catches findings early, while the install-time reachable-module scan is authoritative for the exact compiler and dependency graph being assembled. A package built with different policy or compiler inputs can therefore still be rejected at install.
How the analysis works
Two views of the source are combined, because neither alone is sufficient.
A Motoko-aware lexer preserves facts the bundled simplified AST erases. It skips nested comments, strings, and character literals; distinguishes brace-based record extension from parenthesized call contexts; and reports source lines for diagnostics.
The AST decides what counts as an acquisition. Only dotted members and object-pattern fields are name-based acquisitions.
That distinction handles the covered aliasing forms. Renaming a privileged
primitive during import or destructuring does not escape these rules, because
the pattern field is itself an acquisition. Meanwhile harmless local names,
type fields, and record-literal keys stay usable—you can have a variable called
cycles.
The two compilers combine these differently:
- The browser starts from a compact member inspection plus source-only facts. When the lexer sees a privileged spelling that compact facts do not explain, that one module falls back to full AST parsing. The spelling is a fallback trigger, never itself a finding.
- The packager always parses the full AST and intersects readable text candidates with its authoritative findings.
What is blocked
Actor and shared-function references
Actor URLs, actor and service types, actor classes, and shared-function
references — including caller-supplied ones — plus actorOfPrincipal,
toActor, createActor, and call_raw.
Passing an actor reference in is blocked deliberately. Otherwise a caller could supply an arbitrary remote call target without the app constructing one.
Safe replacement: pass
Principalidentity values, and use the compiler-injectedbackend_callsbroker for outbound calls.
Cycle primitives
cyclesAdd, the remaining cycles* system primitives, explicit
(with cycles = …), and inherited (context with …) call contexts.
These can spend, accept, forward, inspect, or burn the shared cycle balance of the whole canister.
Safe replacement: the
backend_callsbroker takes an amount and enforces per-call and per-day ceilings; the kernel performs the attachment.
Raw stable memory and Regions
stableMemorySize / Grow / Load* / Store*, stableVarQuery, the complete
regionNew / Id / Size / Grow / Load* / Store* surface, and raw
stable-memory runtime counters.
These bypass managed memory ownership, quotas, schemas, and migrations.
Safe replacement: declared managed memory roots for structured state, or the
stable_storecapability for bounded binary key/value data.
Certification APIs
getCertificate and setCertifiedData. These interfere with the certified HTTP
data the kernel uses for every static asset.
Safe replacement: the
certified_assetscapability.
System capability and environment
Direct <system> syntax, raw caller attributes, canister environment access,
process-wide Candid serialization limits, and raw timer primitives.
These bypass kernel-owned request policy, configuration, scheduling, and the combined actor's lifecycle. Candid limit setters are blocked specifically because they mutate serialization policy shared by every app in the actor.
Safe replacement: the
callercompiler-injected argument, and thescheduled_taskscapability for recurring work.
Reserved names and where they apply
Reserved names are blocked as .member access and in any object pattern,
including a local record destructure, because arbitrary aliases can hide a
value's provenance.
Bare local names, bare function names, record type fields, and record literals are not acquisitions and remain allowed.
The Region and stable-memory rules enumerate the exact primitive suffixes
supported by the pinned compiler, rather than reserving unrelated prefixes — so
a legitimate identifier that merely starts with region is fine.
The attenuated-closure exception
PublicIngressCyclesV1 looks like an exception and is not.
Its logical available / request API can only record a bounded supplemental
request inside one exact handler invocation. It cannot accept cycles; only the
kernel performs any later acceptance. It is a compiler closure, not an
exposed system primitive — which is the general pattern for every capability
leaf.
The Core whitelist
Two identical hash maps record exactly two reviewed pinned Motoko Core modules:
Principal.mo and Runtime.mo.
The rule is precise:
If a final module's content hash matches an entry, the dangerous findings recorded for that exact content are accepted.
It does not trust a package name, an import path, or a version string. It trusts a byte sequence that has already been read. Core modules exposing cycles, certified data, or raw IC calls remain disallowed.
Why Principal.mo. The pinned Core version records an actor type and
actorOfPrincipal acquisition to implement its public toActor field. The
field declaration is not a finding. The scanner still rejects
Principal.toActor, a named toActor import, or an object-pattern acquisition
in the untrusted calling module. Safe principal parsing and comparison stay
available without turning a whole-module exception into ambient actor authority.
Why Runtime.mo. Ordinary collection modules depend on its safe trap and
unreachable helpers, while the same facade declares environment-variable
helpers requiring <system>. An app's own <system> acquisition and calls to
those helpers remain rejected at the call site.
Why Random.mo is absent. It embeds paid management-canister raw_rand
access. Apps that need entropy declare the randomness capability and receive
an installation-scoped handle with concurrency, result-size, lifecycle, and
cycle-reserve enforcement.
mo:core is the supported package; the official packaging path rejects direct
mo:base imports. That is dependency hygiene — the reachable-module scan and
the exact content hashes are the security boundary.
The kernel exception
When the manifest id is kernel, findings are computed but permitted. Kernel
code legitimately owns actor assembly, cycles, timers, stable memory,
certification, assets, and upgrades.
This bypass is why a package whose id is exactly kernel must be treated as a
whole-kernel replacement, not as confined app code. The reviewed kernel UI
classifies that replacement at its highest severity, but the static checker
itself does not provide the install authorization or user decision.
Fixtures
The package ships allowed/ and disallowed/ sample modules, a checker that
runs the policy over both, and a test suite covering the same logic. Unexpected
parser failures fail the suite. One retained legacy fixture is an expected
parser failure, because the obsolete form it contains is rejected before policy
matching begins.