# The plugin sandbox

> What the Python plugin sandbox can do, how that is enforced, and where the limits are.

URL: https://wmkeyboard.pages.dev/plugins/security/
Section: Plugins

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

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.

The app collects and shares nothing new because of this feature.

## How it is enforced

### The interpreter

Plugins run on `wm-python`, a Rust port of
[PyMini](https://github.com/anti-ltd/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.

### Stopping runaway scripts

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.

### Memory

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.

### Cross-plugin isolation

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 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 `TextField`s 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.

### 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.

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.

## What this does not protect against

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.

## Reporting

Found something wrong with any of this? Please open an issue on the
[WM Keyboard repository](https://github.com/wasi-master/WMKeyboard). 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.
