The static check every staged file passes before it is compiled (the
pipeline's step 2, see Rho.Dyn), and beam/2, a second pass over the
compiled binaries.
A best-effort guard against mistakes, not a sandbox. It is there for the model's mistakes: a tool that by accident calls something it shouldn't, reaches into the Core, or installs code in a process that runs for others. It does not hold against code written to get around it; the BEAM has no capability isolation. Known ways around it, for a host with stronger needs:
- an atom handed in as data and called without parentheses, a function value smuggled in through a message;
- raw messages to OTP's processes, a
report_cbsent to:logger_proxysay, which then runs Dyn code there; - modules the deny-lists don't name: Logger.App (stopping Logger
drops
Rho.Dyn.LogGuard),Logger.Backends.*,LoggerBackends; - starting a child under a trusted supervisor (
:supervisor,Supervisor,DynamicSupervisorcall the child's start function in the supervisor),:alarm_handler(a:gen_eventwrapper); - CPU, memory and message floods.
What keeps a mistake from sticking is the rest of the pipeline: versioned modules, selftests in contained processes, screen-lock approval, probation and automatic revert.
Source rules (each violation carries file:line; <ns> is the
namespace, Rho.Dyn.Config):
- only
defmoduleat the top level of a file, named<ns>.*(<ns>.G<n>is reserved for compiled generations); nodefprotocol, macros (defmacro,quote,unquote) or@on_load;defimplonly inside a module and for a Dyn module (for:left out, or a<ns>.*module), so the implementation is compiled as<Protocol>.<ns>.G<n>.X, whichbeam/2lets through; - no calls to, or references of:
:code,Code,Module,Port,Node,Mob.Dist,MobDeliver(the Core's own updates),File(Dyn code's files go through a Core module the host allows, inside its roots),Path.wildcard/2,:init,:file,:os.cmd,:erlang.haltand the other code-loading and node-control functions listed in the:partialdefaults,System.halt/stop/cmd,Application.start/stop/put_env, dynamic atoms (String.to_atom, ...),:erlang.binary_to_term(it makes any atom, a forbidden one too, and funs), andProtocol.derive/consolidate(they define or replace implementations for any type, the stdlib's too;@deriveanddefimplfor a Dyn module are the way); - nothing that controls or watches other processes or the VM: tracing
(
:erlang.trace*,:trace,:seq_trace,:dbg,:erl_tracer),:erlang.suspend_process/resume_process,system_monitor/system_profile,:erts_debug,:erts_internal,:observer_backend, and listing every process (Process.list/0,:erlang.processes/0);:persistent_termonly to read (put/erasechange VM-wide state and trigger a global GC); - nothing that installs code to run in other processes:
:logger,:error_logger,:logger_backends,:telemetry,:gen_event,:gen_statem(it calls any OTP server), andLogger's translators, backends and configuration (Logger.info/1and friends are fine); a Dyn process's own crash report is kept out of the logger's processes byRho.Dyn.LogGuard; - not the router's name,
:mob_screen: whatever arrives there is handed to the front host as a native reply, so a front screen sending to it could forge a capability grant (a picked file); nor:mob_file_result, the tag of a raw native file result, which the front host decodes and grants when one arrives; nor the native layers:mob_nifand:mob_secure_store_nifas values, since one held in a variable could be called without parentheses (:forging, atoms Dyn code may not hold); - the Core's own modules (
Rho.*and the host's:coreprefixes) are off limits exceptRho.Tool(the behaviour a Dyn tool implements) and the host's:allowedones, and so is the sign-in store (MobSecureStore, its NIF:mob_secure_store_nif); of Mob only the app-facing modules (screens, UI, theming, device features; the:gateddefaults), not its native layer:mob_nif(Mob's Elixir modules are the API, and the host decides which of them Dyn code gets), and ofMob.Screennot the functions that start or drive screens outside the front (start_root/3, ...); the capability plugins (MobCamera,MobLocation,MobSensors,MobMishka,MobThemes, ...),NxandNxTfliteMobare allowed; Process.exit/2only onself();apply/3,spawn/3and friends only with a module written out literally; no calls on a module held in a variable (mod.fun()); no:"Elixir.Foo"atoms; modules with forbidden functions (System,:erlang,Process, ...) may be called but not passed around as values or imported.
Aliases (alias System, as: S), multi-aliases and ~MOB templates (whose
{...} expressions only exist once the sigil expands, :sigils) are
resolved first, so S.halt() and <Text text={System.halt()} /> are
caught too.
The lists are a host seam (config :rho, :dyn, check: [...], merged over
defaults/0: Operator's lists minus the entries naming Operator's own
code; see Rho.Dyn.Config).
Summary
Functions
The backstop on compiled generation n: every module of the result is
the generation's (<ns>.G<n>.*) or a protocol implementation for one of
them, named as Elixir names it (Inspect.<ns>.G<n>.X, from a defimpl
or a library's use), and none may call a forbidden function (import
table) or name a forbidden module (atom table), whatever macro produced
the code.
The default lists, as config :rho, :dyn, check: [...] takes them:
Operator's, except for the entries that name Operator's own code (its
sign-in NIF :operator_secure_store, and the caller-side functions of
Operator.Core.Files), which its config adds. The host's entries are
merged over these, keys written as modules or strings alike (a nil
value drops one; :handlers replaces the list).
Violations as text, one file:line: message per line (what a rejection's reason says).
Is mod (bin) one of generation n's modules, or a protocol
implementation for one of the build's (mods), named as Elixir names it?
The check's rules as they apply now: the defaults with the host's config over them.
Parses and checks sources (%{relative_path => source}). Returns the
parsed files, or every violation found.
The template sigils whose expressions are code (:sigils), as their macro names.
Types
@type violation() :: %{ file: String.t(), line: non_neg_integer() | nil, message: String.t() }
Functions
@spec beam([{module(), binary()}], pos_integer()) :: :ok | {:error, [violation()]}
The backstop on compiled generation n: every module of the result is
the generation's (<ns>.G<n>.*) or a protocol implementation for one of
them, named as Elixir names it (Inspect.<ns>.G<n>.X, from a defimpl
or a library's use), and none may call a forbidden function (import
table) or name a forbidden module (atom table), whatever macro produced
the code.
@spec defaults() :: keyword()
The default lists, as config :rho, :dyn, check: [...] takes them:
Operator's, except for the entries that name Operator's own code (its
sign-in NIF :operator_secure_store, and the caller-side functions of
Operator.Core.Files), which its config adds. The host's entries are
merged over these, keys written as modules or strings alike (a nil
value drops one; :handlers replaces the list).
Violations as text, one file:line: message per line (what a rejection's reason says).
Is mod (bin) one of generation n's modules, or a protocol
implementation for one of the build's (mods), named as Elixir names it?
@spec policy() :: map()
The check's rules as they apply now: the defaults with the host's config over them.
@spec run(%{required(String.t()) => String.t()}) :: {:ok, [{String.t(), Macro.t()}]} | {:error, [violation()]}
Parses and checks sources (%{relative_path => source}). Returns the
parsed files, or every violation found.
@spec sigils() :: [atom()]
The template sigils whose expressions are code (:sigils), as their macro names.