The plugin sandbox
What the Lua sandbox can do, how that is enforced, and where the limits are.
What plugins can do, how that is enforced, and where the limits honestly are.
Written for three audiences: users deciding whether to trust the feature, developers who want to know what they are working inside, and reviewers checking what third-party code on a keyboard is allowed to reach.
The invariants
Section titled “The invariants”Each of these is a property of what was built, not a policy that could be configured differently. Each has a test.
- Plugins cannot read what you type. There is no key-event API. The IME’s typing, prediction and autocorrect paths have no plugin hook at all — a plugin cannot observe, alter or delay a single keystroke on its way to your app.
- Plugins cannot read the text field. No
InputConnectionread path is exposed; no function returns the contents of what you are writing in. - Plugins cannot read the clipboard. No API.
- Plugins cannot reach the network. No HTTP client, no sockets, no URL handling. The sandbox has zero egress.
- Plugins cannot execute downloaded code.
load,loadstring,dofileandloadfileare removed and no bytecode undumper is installed, so a plugin cannot turn a string into runnable Lua. Precompiled Lua is refused at install. - Plugins cannot reach Android. No
luajava, norequire, no reflection, no class loading, noio, noosbeyond the clock, no file paths, no Intents, noContext. - Plugins cannot see other apps or the device. No package enumeration, no identifiers, no location, contacts, camera, microphone, SMS or accessibility.
- Plugins cannot run in the background. Execution exists only while the plugin’s panel is open. No timers, no callbacks that outlive it.
- Plugins cannot change the app. They render inside their own panel below a host-drawn title bar, and cannot alter keyboard behaviour, request permissions, or affect any other feature.
Anything a plugin holds, you gave it on purpose — and it has nowhere to send it.
How it is enforced
Section titled “How it is enforced”The interpreter
Section titled “The interpreter”Lua 5.2 via LuaJ 3.0.1, a pure-JVM interpreter
with no native code. Each plugin session gets a fresh environment holding only
BaseLib, Bit32Lib, TableLib, StringLib, JseMathLib and the compiler.
The omissions matter more than the inclusions:
PackageLibis never installed. Itsrequireresolves module names throughClass.forNameand instantiates the result — a straight line from a script to arbitrary JVM classes. This is the single most important library not to install.CoroutineLibis never installed. LuaJ implements coroutines as real, non-daemon Java threads and reclaims them only when they yield, so a script spawning non-yielding coroutines would leak OS threads that outlive the keyboard.IoLibandOsLibare never installed.osis replaced by a pure-Kotlin table that can tell the time and nothing else.LuajavaLibis never installed — and, because nothing references it, R8 strips it from the shipped APK entirely, along with the JVM-coercion helpers, the bytecode backend and the JSR-223 engine. Verified against the release build’s mapping file: the reflective interop surface is not in the binary.
The debug library is loaded, because the interpreter’s per-instruction hook
lives on it, and then removed from the environment so no script can reach it.
Stopping runaway scripts
Section titled “Stopping runaway scripts”Two mechanisms, because one is not enough.
The interpreter calls a hook before every bytecode instruction. That hook spends
an instruction budget and checks a wall-clock deadline, and throws when either
runs out. The abort extends java.lang.Error rather than Exception
specifically because LuaJ’s pcall catches LuaError and Exception only — so
a script cannot swallow its own termination in a pcall loop. There is a test
that proves exactly that.
What the hook cannot catch is a thread inside LuaJ’s own Java code, where no
bytecode boundary is crossed — a pathological string.gsub is the realistic
case. A watchdog covers that: it notices a call past its deadline with the hook
plainly not firing, and abandons the thread.
Abandoning is not killing, and it is worth being precise. Thread.stop is gone
from the platform and LuaJ never polls for interrupts, so a thread spinning in
Java keeps spinning. What abandonment does is sever it — the session is revoked
so every host call it might still make throws instead of acting, its executor is
shut down, its priority is dropped to minimum, and the runtime forgets it. It
burns CPU as a background daemon until the process ends, and can affect nothing
else. A plugin that does this twice is switched off until the user re-enables
it.
Memory
Section titled “Memory”There is no per-thread heap limit on Android, so this is layered rather than
absolute: the script is capped at 256 KB, the instruction budget bounds the
allocation rate, string.rep and the pattern functions and table.concat are
wrapped with output caps, every string crossing the API boundary is capped, the
widget tree is capped, storage is quota’d, and only one plugin runs at a time.
Honest residual: repeated string doubling (s = s .. s) reaches gigabytes in
about twenty instructions, and concatenation is not something the hook can price.
The allocating thread gets an OutOfMemoryError, which is caught at the session
boundary and drops the environment; worst case the OS kills the keyboard process
and it restarts clean. That is a crash, not a leak — no data crosses any
boundary — and the strike system disables a plugin that does it repeatedly.
Cross-plugin isolation
Section titled “Cross-plugin isolation”LuaJ’s string metatable is a process-global static, populated from whichever
environment is built first. Left alone, one plugin writing to
getmetatable("").__index would rewrite string methods for every other plugin in
the process and for every session afterwards. It is replaced with a frozen table
that refuses writes and hides itself behind __metatable, so getmetatable("")
returns an opaque marker and no reference escapes.
Everything else is per-session: a fresh environment per plugin per panel opening, discarded when the panel closes.
The key-routing gate
Section titled “The key-routing gate”The one mechanism that could break invariant 1 if it were wrong.
A plugin’s text box is not a real text field — there are no TextFields anywhere
in the keyboard UI, since they fight the InputConnection. Tapping one instead
makes the keyboard route keystrokes into a host-owned buffer, and hand the
plugin the resulting contents. While that routing is on, what the user types
goes to a script rather than to their app, so:
- it requires both the Plugins panel to be open and a widget to be focused;
- it is gated at all five keystroke entry points (characters, backspace, space,
enter, gesture typing), each returning before the
InputConnectionis touched; - it is switched off when the panel closes, when the keyboard hides, when the focused field changes, and when the plugin stops drawing that widget;
- the runtime is torn down at the same moment, so afterwards there is no plugin left in the process to receive anything.
Distribution
Section titled “Distribution”Plugins arrive as .wmplugin archives from an addon repository or a local file.
Archive entry names are never used as filesystem paths, the plugin id is proven
to be a single safe lowercase path segment before it becomes a directory, and
archives are capped in size and entry count.
For plugins alone, a SHA-256 is mandatory — for every other addon type a missing checksum only means “unverified”, but the app will not install code it cannot verify.
The whole subsystem is off until the user turns it on, and while it is off nothing installs and nothing runs. Before any install, the user is shown what the plugin says it is and what it would be allowed to do; only the manifest is read to build that screen, never the script.
What this does not protect against
Section titled “What this does not protect against”Stated plainly, because a security document that only lists strengths is not useful.
- A bug in LuaJ itself. The interpreter runs in the app’s process, so an
interpreter escape would be an app compromise. Mitigations: no
luajava, no reflection surface, no class loading, minimal host objects (plain functions over immutable strings and numbers), and a pinned version whose relevant internals were audited. The runtime is kept behind an interface so it could be moved into anisolatedProcesslater; that is not done today. - A plugin that wastes CPU. Contained, not prevented — see abandonment above. A determined script can burn one background thread until the process ends.
- A plugin that lies about what it does. Nothing stops a plugin called “Calculator” from being a poem generator. It still cannot read your text or reach the network, so the worst case is a waste of your time.
- Anything you paste into it. If you paste a password into a plugin’s box, that plugin has your password. It cannot send it anywhere, but it is worth saying out loud.
Reporting
Section titled “Reporting”Found something wrong with any of this? Please open an issue on the WM Keyboard repository — a way for a plugin to reach text, the clipboard or the network is a security bug, not a feature request, and will be treated as one.
