Skip to content
WM KeyboardWM Keyboard
Accessibility

The plugin sandbox

What the Python plugin 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.

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.

  1. 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.
  2. Plugins cannot read the text field. No InputConnection read path is exposed. No function returns the contents of what you are writing in. A plugin with the selection permission is handed one thing: the text you selected, when you tap one of its buttons on the selection bar. Never without the tap, never more than the selection, never on a password field. Each tap runs in a fresh interpreter that is discarded afterwards, so the text outlives the call only if the plugin writes it to its own storage, which has nowhere to send it.
  3. Plugins cannot read the clipboard. No API.
  4. Plugins cannot reach the network. No HTTP client, no sockets, no URL handling. The interpreter has zero egress.
  5. Plugins cannot execute downloaded code. There is no eval, exec, compile or __import__, and import reaches six built-in modules and nothing else, so a plugin cannot turn a string into code that runs. There is no bytecode format to smuggle one in either: a plugin is source, read by the interpreter’s own parser.
  6. Plugins cannot reach Android. No open, no os, no subprocess, no reflection, no class loading, no file paths, no Intents, no Context. The interpreter is native code that never calls back into the app: the app hands it JSON and reads JSON back, and that is the whole connection.
  7. Plugins cannot see other apps or the device. No package enumeration, no identifiers, no location, contacts, camera, microphone, SMS or accessibility.
  8. Plugins cannot run in the background. Execution exists only while the plugin’s panel is open. No timers, no callbacks that outlive it. A plugin with the reminders permission can leave a time and a line of text with the app; at that time the app, not the plugin, shows the text in a notification titled with the plugin’s name. No plugin code runs for it.
  9. 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: typed or pasted into its panel, or selected and handed to it with a tap. It also has nowhere to send it.

Plugins run on wm-python, a Rust port of PyMini, the small Python interpreter written for Clink. The app loads it as a native library over JNI; the web editor’s preview runs the same code compiled to WebAssembly.

The sandbox is mostly a matter of what was never written. PyMini has no file, network or process access because none exists in it, not because a check stands in the way. import reaches math, re, json, time, random and sys, and each of those only computes: time reads the clock, random draws from a seedable generator, sys holds a version string. The built-in functions have no open, eval, exec, compile, getattr or __import__, and there are no classes, so there is no object model to climb through to reach something the API left out.

The boundary with the app is coarse on purpose. The app passes in a plugin’s source, its settings and each event as JSON, and gets JSON back: a widget tree, an error, log lines, writes to its own input boxes. The interpreter never calls into the JVM, so no host object is ever within a script’s reach. Storage and reminders are files the interpreter itself reads and writes, at the paths and under the quotas the app gives it when it opens the session.

Every statement, every turn of a loop and every step of a regular expression’s matching spends from a step budget. Each step also checks a cancel flag and a wall-clock deadline. When the budget or the time runs out, or the flag is set, the interpreter unwinds and returns an error that no try in the script can catch. A script cannot swallow its own termination.

Regular expressions run on PyMini’s own backtracking engine, and that engine spends steps like everything else. A pattern that backtracks for ever is stopped the same way a while True: is, which closes the usual gap where an interpreter hands a regex to a library it cannot interrupt.

The app also keeps a watchdog. When a call passes its deadline, the watchdog sets the cancel flag from its own thread, and since the flag is checked on every step, the call stops. Abandoning the thread is now only a fallback for a call that does not return even then. Abandoning is not killing: the session is revoked so nothing the thread does afterwards reaches anything, its priority is dropped, and the runtime forgets it.

A plugin that runs out of steps or time, or has to be abandoned, takes a strike. Two strikes and it 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 step budget bounds the allocation rate. No string can pass 100,000 characters, no list, dict or set 10,000 items, and no call chain 300 calls, so the doubling trick (s = s + s) stops after a few turns with an error instead of reaching gigabytes. Every string crossing the boundary with the app is capped, the widget tree is capped, storage is quota’d, and only one plugin runs at a time.

Honest residual: those caps bound each value, not the total. A script that fills many containers with long strings can still use a lot of memory before its steps run out. Worst case, Android ends the keyboard process and it restarts clean. That is a crash rather than a leak: no data crosses any boundary.

Every session is its own interpreter, with its own globals and its own modules, created when the panel opens and discarded when it closes. A selection action gets a fresh one per tap. Nothing a script can write to is shared with another plugin or with a later session, so one plugin has no way to change how Python behaves for the next.

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 InputConnection is 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.

The interpreter is a feature library rather than part of the APK. It is fetched when plugins are first turned on, or the editor first opened. A Play install gets it from Google Play. Every other build gets it from the project’s data repository and checks it against the checksum the app was built with before loading it.

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 interpreter 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 the interpreter itself. It runs in the app’s process, so an interpreter escape would be an app compromise. Mitigations: it is written in Rust, which rules out the memory-corruption bugs that usually turn an interpreter flaw into an escape, outside the thin JNI layer; it holds no host objects at all, only JSON in and out; and it has no reflection, no class loading and no file or network code to misuse. The runtime is kept behind an interface so it could be moved into an isolatedProcess later. That is not done today.
  • A plugin that wastes CPU. Contained rather than prevented. Every call is bounded by its step budget and deadline, so the waste ends when the call does, and a plugin that keeps hitting the limit is switched off.
  • 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, or select and hand to it. If you paste a password into a plugin’s box, or select one and tap a plugin’s action, 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.