Skip to main content

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:

PathWhenEffect
PackagingEvery final module of a package being builtFindings print the offending source and throw
CompilationEvery reachable module before assemblyFindings 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 Principal identity values, and use the compiler-injected backend_calls broker 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_calls broker 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_store capability 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_assets capability.

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 caller compiler-injected argument, and the scheduled_tasks capability 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.