-
Notifications
You must be signed in to change notification settings - Fork 4
The plugin sandbox
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.
Each of these is a property of what was built. None of them is 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. It also has nowhere to send it.
Data safety
The app collects and shares nothing new because of this feature.
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, which is 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. Nothing references it, so 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.
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. A
script therefore 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 it: the watchdog 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 the thread 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. The one exception is a user who has turned Switch off a plugin that hangs off (Tools / Plugins, on by default). With it off, the strikes are still counted and still shown, so the user keeps the evidence and gives up only the automatic switch-off. That is the trade you want if you write plugins, or if a slow device is the real cause.
There is no per-thread heap limit on Android, so this is layered rather than
absolute. The script is capped at 256 KB, and the instruction budget bounds the
allocation rate. string.rep, 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, caught at the session boundary,
which drops the environment. Worst case, the OS kills the keyboard process and it
restarts clean. That is a crash rather than a leak: no data crosses any boundary.
No strike is counted for it, though. Strikes come from the instruction and
deadline aborts and from the watchdog, so a plugin that runs the heap out ends
its session and stays switched on.
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 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. A tap on 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 every keystroke entry point that could reach the field:
characters, backspace, the backspace swipe, forward delete, space and enter
each return before the
InputConnectionis touched, and a hardware keyboard runs through the same handlers. Gesture typing needs no gate here, because glide is only armed while no panel is open at all; - 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.
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". The app will not install code it cannot verify.
The whole subsystem is off until the user turns it on. While it is off, installing from a file or the addon catalogue is refused outright.
The in-app plugin editor is the exception. Its live preview runs a script as you write it. Its Install button writes the plugin straight to disk the same way a catalogue install would, both before you ever turn the switch on. Since you already wrote the manifest yourself, Install skips the confirmation screen described below. Both still go through the same sandbox as an installed plugin, so every invariant above still holds.
Before installing from a file or the catalogue, the user sees 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.
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 rather than 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.
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 rather than a feature request, and it will be treated as one.
- Home
- Accessibility
- Addons
- Development
- Emoji
- Languages
- Plugins
- Privacy
-
Reference
- Gesture cheat sheet
- Typing
- Hardware shortcuts
- Deep links & launcher shortcuts
- Key press
- Link builder
- Dictionaries & words
- File formats
- Languages
- Importing from other keyboards
- Appearance
- Importing from Espanso
- Keyboard themes
- Keyboard font
- Troubleshooting
- Glossary
- Icons
- Easter eggs
- Layout & size
- Key layouts
- Rows & bars
- Keyboard modes
- Emoji
- Phone number formats
- Tools
- Addons & plugins
- Reference - Accessibility
- Fingerprint lock
- Reference - Data saver
- Reference - Permissions
- Privacy
- Reference - Selection actions
- Servers
- Reference - Backup & restore
- About & diagnostics
- Statistics
- Settings A–Z
- Smart
- Start
- Themes
-
Tools
- Clipboard manager
- Voice typing
- Offline voice (Whisper)
- Handwriting
- Scanner (OCR, QR, documents)
- Camera tool
- Translate
- Search, Wikipedia & dictionary
- Media controls
- AI chat
- AI tools
- Utility tools
- Snippets & text expansion
- Text editing & cursor tools
- Instruments
- Trackpad
- Calendar
- App launcher
- Learn from text
- Vocabulary
- Resize the keyboard
- The toolbar
- Typing