Skip to content

Runtime and sandbox

This page describes what actually runs your plugin, what it can and cannot do, and which limits apply. It is written from the plugin host sources (Telegram/plugins_host) and the Python SDK (Telegram/plugins_sdk/python). For the byte-level contract of Wasm modules see Wasm ABI.

  • Every enabled plugin runs in its own host process (fgpl_plugin_host), one process per plugin and account. The app starts at most two hosts at a time while plugins are starting.
  • The host process is a small native program that embeds Wasmtime 50. Both runtimes, Python and Wasm, execute as WebAssembly inside it. There is no native Python and no separate interpreter process: Python is CPython compiled to WASI (python.wasm).
  • The app talks to the host over one private channel (a socket pair on Linux and macOS, a pipe on Windows) that the host inherits as its only open descriptor. Messages are CBOR maps with a 4-byte big-endian length prefix, at most 256 MiB each. The host starts with an empty environment.
  • The host applies its sandbox to itself before it loads any plugin code, then reports what was enforced. If any layer is missing, the app refuses to use the host (“the host started without its full sandbox”).
  • The host binary is checked against a hash from the signed runtime index and started from a sealed copy (on Linux an anonymous in-memory file), so it cannot be replaced between check and use.
  • The app delivers events to a plugin one at a time. While a handler runs, new events wait in a queue of at most 256 events or 256 MiB; beyond that they are dropped (and counted in the plugin’s statistics). Hooks that rewrite outgoing or incoming messages have their own queue of 64.
Interpreter CPython 3.14.7, the WASI build (wasi-sdk-24) from brettcannon/cpython-wasi-build
Engine Wasmtime 50.0.0 (Cranelift), no JIT in Python itself
Command line python -S -B /plugin/<entry>
Environment PYTHONHOME=/runtime, PYTHONPATH=/runtime/sdk:/plugin, PYTHONDONTWRITEBYTECODE=1, PYTHONNOUSERSITE=1, PYTHONUTF8=1, PYTHONUNBUFFERED=1, PYTHONHASHSEED=0
Files /runtime is read-only: lib/ (the standard library of that build) and sdk/freshgram. /plugin is read-only: the files of your package. Nothing else exists
Memory the manifest’s memoryMb, but at least 128 MiB for Python

-S means the site module is not imported, so there is no site-packages and no .pth processing. The only import paths are the standard library, the SDK and your package. Pure-Python modules you ship in the package are importable; native extensions are not (the package may not contain .so, .dll, .pyd or other binaries, and a WASI Python cannot load them). Extension modules exist only if they were built into python.wasm. manifest.json’s runtime.pythonVersion is read and stored, but the host does not compare it with the installed interpreter.

The runtime is not part of the app update. It is a signed bundle (runtime-<platform>.zip: the host binary, python/python.wasm, python/lib, sdk/) that the app downloads and verifies: an Ed25519-signed index lists a revision number, the Wasmtime and Python versions, and SHA-256 hashes of the archive, the host binary and python.wasm. The index can raise a minimum revision or revoke revisions. This means that the Python version and the SDK files can change between app releases; do not assume a fixed micro version.

Because the interpreter runs on the WASI layer described in the Wasm ABI, the following behave differently from a normal Python:

  • No files besides the two read-only trees. open(..., "w"), os.mkdir, tempfile and the like fail. Use storage.*.
  • No sockets, no subprocesses, no threads. socket, ssl, subprocess, threading may import but cannot do real work. Use net.* operations or freshgram.aio helpers.
  • No blocking sleep or polling. time.sleep() and select are built on poll_oneoff, which the host does not implement (ENOSYS). Use await freshgram.aio.sleep(seconds) or timers.
  • Clock is coarse: time.time() and time.monotonic() are truncated to 1 ms. Other clocks (CPU time) are not available.
  • Randomness (random, secrets, os.urandom) comes from the operating system’s secure generator.
  • print() does not go to stdout. See below.

The real standard output (fd 1) of the interpreter is the protocol channel to the host. The SDK captures it at import time and then sets sys.stdout = sys.stderr, so print() goes to fd 2. Everything written to fd 2 is turned into log lines, one per \n; a line longer than 4096 bytes is split. Do not write raw bytes to fd 1 yourself (os.write(1, ...), a C-level library, sys.__stdout__): anything that is not a valid protocol frame is a protocol error and the plugin is killed.

Log limits: the host accepts at most 1 MiB of log text per handled event and drops the rest; the app keeps the last 200 lines of each plugin. Besides print, freshgram.log(text) writes through the log operation. Unhandled exceptions in handlers are logged with the traceback (trimmed to the last 4000 bytes).

The SDK (freshgram/_channel.py) speaks this protocol on stdin/stdout; you only need it if you write your own loop or another command-mode runtime.

Frame. uint32 big-endian length, then that many bytes of a CBOR map. The maximum frame is 128 MiB in both directions (the SDK’s MAX_FRAME; the host’s message limit is the same). A larger frame, an invalid frame, or a CBOR value beyond the limits (depth 32, 65536 items, 16384 entries per container) makes the host mark the plugin dead. The Python SDK itself is stricter: it refuses CBOR deeper than 24 levels when encoding and decoding (ValueError).

Keys. t is the message type, v the payload.

Direction t Fields Meaning
host → plugin init id, v = the info map start handler, once
host → plugin event id, v = the event map an event
host → plugin reply v = the response the answer to the last call
host → plugin (EOF on stdin) stop: leave the loop and exit
plugin → host call v = {"op": ..., "a": {...}, "async": ...} a host call; the host answers with exactly one reply
plugin → host done status (integer) the current init/event is handled

Rules:

  • v of a call is the same request map as in the Wasm ABI; v of a reply is the same response. The answer to a call is always a reply, also for errors ({"ok": false, ...}); while waiting, the SDK reads frames until a reply arrives.
  • After handling an init or event, send {"t": "done", "status": N}. status has the meaning described in Return values. If you read the next message without sending done, the host closes the previous one with status 0 for you.
  • Any other t from the plugin is a protocol error and kills the plugin.
  • The stop signal is end of file on stdin; there is no stop frame for the plugin. freshgram.run() returns on EOF, and the script should simply end. If the interpreter exits while an event is open, the event fails.
  • Async calls ("async": true) are answered at once with a ticket; the result comes later as an event with type call.result. Asynchronous results are just events in the queue, delivered in order.

What freshgram.run() does, in short: read a frame; for init/event call your registered handlers (a coroutine returned by a handler is started on the SDK’s own tiny event loop); resolve call.result events into awaiting coroutines; run whatever became ready; send done. An exception in a handler is logged and the status is -1; SystemExit or other non-Exception errors end the script and fail the plugin. The script must reach freshgram.run(); if it returns without it, the start fails.

Defaults; the manifest can change those marked with a field name (runtime.limits).

What Limit
Memory of the Wasm instance memoryMb, default 64 (1–1024); Python at least 128
Address space of the host process 1 GiB (hard limit set by the OS where supported)
Time per event eventTimeoutMs, default 500 ms (10–60000); time spent in host calls is not counted (up to 60 s in total per event).
Python start: from process start to the first message 10 s (interpreter startup and the top level of your script)
Host calls per event 256 (more are answered with limit); the host is killed over 512
Host calls per second 200 (limit beyond that)
Pending async calls 32
Message and frame size 128 MiB (host calls and events); host-IPC frame 256 MiB
Storage storageMb, default 10 MiB; at most 4096 keys; key at most 128 bytes; one value at most 1 MiB encoded
Plugin-to-plugin message 64 KiB, depth 12; at most 1024 undelivered messages wait in the app’s mail queue, beyond that plugin.message fails with limit
Open file handles in the VFS 64; the virtual file system holds at most 16384 files, 4096 directories, 256 MiB, nesting up to 32 levels, names up to 128 bytes and whole paths up to 512 bytes
Worker threads for asynchronous host calls 8 in the app, shared by all plugins; work that does not fit waits in a queue
Request to an ai.provide plugin text is 1 byte to 32 KiB; tone and prompt up to 2048 bytes each; target language up to 12 characters; otherwise the request fails with invalid
Precompiled module (cache entry) up to 4 x the 32 MiB module limit, that is 128 MiB
Logs 4096 bytes per line before splitting; 1 MiB of log text per event; 200 lines kept
Wasm call stack 512 KiB

On top of these each operation has its own throttling: for example sending messages is limited to a burst of 5 and then one per second, notifications and toasts have minimum intervals, and requests to the network, files and dialogs have size caps. They are described with the operations in the ops reference and appear as {"e": "limit"} or quota.

A timeout is checked every 10 ms against the time the module has been running, with host calls subtracted. The budget is wall-clock time, not instructions (the engine uses epoch interruption, not fuel), so a slow machine gets less work done within it.

The host is a separate process that restricts itself right after start-up. What is enforced differs per platform; the host reports which layers are active, and the app will not run plugins unless all of them are.

  • no_new_privs, non-dumpable, and SIGKILL if the app process dies. All inherited descriptors except the IPC channel (and stderr) are closed.
  • Resource limits: no core files, no file creation (RLIMIT_FSIZE=0), no locked memory, no POSIX message queues (RLIMIT_MSGQUEUE=0), at most 8 open descriptors, address space capped (1 GiB), and optionally CPU time.
  • Landlock: a ruleset that handles all file-system access and allows nothing, so no path can be opened, read or written. With newer kernels (ABI 4 and up) TCP bind and connect are denied as well; ABI 6 and up also scopes signals and abstract Unix sockets. A kernel without Landlock cannot run plugins.
  • seccomp-bpf: a system-call allowlist (memory management, futex, clocks, random, exit, a few others). read is allowed only on the IPC descriptor, write only on it and stderr. There is no open, socket, connect, execve, fork, clone for processes, and so on; a violation kills the process with SIGSYS. The allowlist has a few argument-checked entries: futex is allowed only for the operations wait, wake, wait-bitset and wake-bitset (0, 1, 9 and 10, after the private and clock-realtime flags are masked off); prlimit64 only as a query (the new-limit pointer must be null, so limits cannot be changed); tgkill only to the host’s own process id. On aarch64 membarrier is allowed only for the sync-core commands. The filter is installed for all threads at once (SECCOMP_FILTER_FLAG_TSYNC); any other call kills the process.
  • Speculative-execution mitigations (store bypass, indirect branch) are forced on.
  • A Seatbelt profile with (deny default) that allows only signals and process info for the host itself and reading a handful of CPU and memory sysctl values. After applying it, the host checks that /etc/passwd can no longer be opened.
  • Core files and file creation disabled, descriptor and CPU limits. RLIMIT_AS does not work on macOS, so the app watches the host’s memory footprint and kills it above the limit; the host also exits if its parent is gone. Debugger attach is denied (PT_DENY_ATTACH).
  • All inherited file descriptors other than the standard three and the IPC channel are closed (descriptors 3 up to the table size, at most 4096).
  • Wasmtime runs without Mach exception ports (not allowed in the profile), so traps are handled by signals.
  • The host runs in an AppContainer (a low-privilege identity with no declared capabilities), with process-creation restricted, and with process mitigations for strict handles, extension points, Win32k lock-down, remote and low-label images, and System32-only DLL loading.
  • It is placed in a Job object with: one active process, kill on close, a process memory limit (committed memory, 1 GiB), a CPU time limit when configured, and UI restrictions (desktop, clipboard, handles, system parameters and so on).
  • The host verifies at start that it really runs in the AppContainer and in the job.

Inside the process, the Wasm module cannot call anything except freshgram.call and the listed WASI functions, and WASI itself is a virtual layer: no real file, socket, clock beyond the wall and monotonic time, or process exists for it. A plugin that somehow escaped Wasmtime would still face the OS sandbox above. The OS layer is tested by self-tests that run what an escaped plugin would try (open /etc/passwd, start a process, allocate memory beyond the limit, spin the CPU) and require the host to be stopped.

Any of these ends the current instance and counts as a failure: a trap, a timeout, running out of the IPC time budget (event timeout plus a grace of 3 s), an invalid or oversized protocol frame, too many calls, memory over the process limit, the host process dying (signal, exit, the sandbox killing it), the interpreter exiting while an event is open, or a failed start. The app then:

  1. logs failure: <reason> to the plugin’s log (the reason text includes the trap message, the signal, or “blocked by the seccomp filter”), and drops the queued events;
  2. does not restart the plugin immediately: the next start is delayed by 1 s, doubling with each consecutive failure up to 32 s;
  3. if the plugin fails 3 times within 10 minutes, disables it: it stays off with the reason “stopped after repeated failures” until the user enables it again;
  4. if the failing version was just installed as an update, rolls back to the previous version and removes the new one.

A nonzero status from init or an event is not a crash (see Return values). An unhandled Python exception inside a handler is not a crash either: it is logged and the loop continues. A plugin can also stop itself with the plugin.unload operation; then the host is shut down without a failure.

State does not survive a restart. Anything in Python variables or Wasm memory is gone; only storage.* data persists, and it is written to disk with a delay (about every 2 s after events), so a crash can lose the last seconds of writes.

What the design guarantees to the user, and so what you may count on:

  • A plugin can reach the outside world only through operations, and every operation is checked against the permissions the user granted for that account (denied otherwise). The plugin cannot read the user’s files, other plugins’ data, the network, or the app’s memory.
  • A plugin cannot run longer, use more memory, or make more calls than the limits above; a stuck or looping plugin is killed without affecting the app or other plugins.
  • Packages and runtime bundles are verified by hashes and signatures before use; native binaries and scripts inside packages are refused.
  • That the sandbox hides granted power. A permission such as messages.send or net.fetch does exactly that; sandboxing limits what a plugin can do beyond its grants, not what a grant allows.
  • That memory survives. The process may be restarted at any time: after a crash, an update, a disable, or an app restart. Keep state in storage.* and be ready for init to run again.
  • That every event arrives. Events are dropped when the queue is full, while the plugin is down, and during backoff.
  • Event order across plugins, or concurrency inside a plugin (events are strictly one at a time, so no locking is needed, but a long handler delays everything after it).
  • Threads, blocking sleeps, sockets, writable files, subprocesses, native extensions, ctypes, or any OS-specific path or variable. The same plugin runs on Linux, macOS and Windows.
  • Precise timing. The clock has a 1 ms step, timeouts have 10 ms granularity, and the first start of a module is slower (compilation, interpreter start-up).
  • A fixed Python micro version, standard-library layout or SDK internals, and in particular the names starting with _ in freshgram. Use the documented API and compat.api in the manifest.
  • Hiding secrets in the package. The package files and the plugin’s storage are readable by the user.
  • The numbers on this page staying constant. They are the current defaults of the host; the manifest runtime.limits and future runtime revisions may change them.

The fgpl_tool program (built next to fgpl_plugin_host) runs a plugin against a small simulated app: three chats (1 Alice, 2 Group, 3 News), a fake network, a virtual clock, and your real runtime and sandbox.

Terminal window
fgpl_tool run <directory|package.fgpl> [--script <file>] [--runtime <dir>] [--no-sandbox]
[--state <dir>] [--folder <dir>] [--grant a,b] [--deny a,b]
[--app X.Y.Z] [--account <name>] [--host <path>]
fgpl_tool pack <directory> <output.fgpl> [--key <name>.key]
fgpl_tool verify <package.fgpl>
Option Meaning
target a plugin directory (with manifest.json) or a .fgpl package. The package is validated like on install; errors exit with code 2
--runtime <dir> a directory with python/ (python.wasm, lib/) and sdk/ as in the downloaded bundle; also FGPL_RUNTIME_DIR. Optional for a tool built from the client sources, which falls back to its built-in CPython and SDK
--host <path> the host binary; also FGPL_HOST. By default fgpl_plugin_host next to the tool
--no-sandbox use fgpl_plugin_host_unsandboxed instead (it exists only in test builds) - useful under a debugger, never a reference for behavior
--script <file> read commands from a file instead of the standard input
--state <dir> keep storage between runs (default: a temporary directory removed at the end)
--grant, --deny comma-separated permissions; by default everything the manifest requests is granted
--folder <dir> the folder for fs.folder
--app, --account the simulated app version (default 7.5.10) and account name (default dev)

The session is a line-oriented REPL (# starts a comment, help prints the list):

event <type> [key=value ...] deliver an event
msg new <chat> <text> [from=N] [out=true] add a message and deliver message.new
msg add <chat> <text> [from=N] [out=true] add a message the plugin can read
chat add <id> <title> [type] add a chat
command <name> [args] [chat=N] run a /command of the plugin
menu <surface> <id> [key=value ...] click a menu item
settings|dialog|window|toast ... click/change/close UI of the plugin
outgoing <chat> <text> run the message.outgoing hooks
timer advance <ms> move the clock so that timers fire
mock <url-prefix> <status> <body|@file> answer net.fetch calls
grant|revoke <permission> change granted permissions
storage | info | wait <ms> | quit

Values are 12, true, null, text, "quoted text", @file (bytes) or key:=<json>. The tool prints the plugin log lines as [plugin-id] ..., the host’s own messages as [host] ... and what the simulated app would do (toasts, dialogs, sent messages) as [app] .... The exit code is 0 when all went well, 64 for bad usage (unknown option, missing value or target) and 1 for I/O errors, 2 for an invalid package or install failure, and 3 when a command failed or the plugin logged a failure: line (including a nonzero init status).

The host binary itself is not meant to be started by hand: it requires an inherited IPC descriptor (--ipc-fd=N, N at least 3, otherwise it exits with 64) and applies the sandbox before doing anything. Its other flags are used by the app and by the test suite: --address-space-mb=N and --cpu-seconds=N (resource limits), --reservation-mb=N (Wasm memory reservation; it also sets the guard region after the memory to 64 KiB), --compile-only (compile a module and return the machine code, used for the cache), --winch (the baseline compiler instead of Cranelift) and --selftest=open|spawn|alloc|spin|env|hang (the sandbox self-tests above), with --selftest-file=PATH to choose the file that the open self-test tries to read (default /etc/passwd, on Windows C:\Windows\win.ini). The --layers=none and --trace= flags exist only in the unsandboxed test build; in the real (sandboxed) host any --layers= argument is refused and the host exits with code 64, and even the test build accepts only none.

Test with the sandbox on at least once before releasing: a plugin that works with --no-sandbox and fails in the real host usually depends on something the sandbox forbids.