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.
Process model
Section titled “Process model”- 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.
The Python runtime
Section titled “The Python runtime”| 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.
What does not work
Section titled “What does not work”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,tempfileand the like fail. Usestorage.*. - No sockets, no subprocesses, no threads.
socket,ssl,subprocess,threadingmay import but cannot do real work. Usenet.*operations orfreshgram.aiohelpers. - No blocking sleep or polling.
time.sleep()andselectare built onpoll_oneoff, which the host does not implement (ENOSYS). Useawait freshgram.aio.sleep(seconds)or timers. - Clock is coarse:
time.time()andtime.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.
stdout, stderr and logging
Section titled “stdout, stderr and logging”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).
Python protocol
Section titled “Python protocol”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:
vof acallis the same request map as in the Wasm ABI;vof areplyis the same response. The answer to a call is always areply, also for errors ({"ok": false, ...}); while waiting, the SDK reads frames until areplyarrives.- After handling an
initorevent, send{"t": "done", "status": N}.statushas the meaning described in Return values. If you read the next message without sendingdone, the host closes the previous one with status0for you. - Any other
tfrom the plugin is a protocol error and kills the plugin. - The stop signal is end of file on stdin; there is no
stopframe 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 aneventwithtypecall.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.
Limits
Section titled “Limits”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.
OS sandbox
Section titled “OS sandbox”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, andSIGKILLif 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).readis allowed only on the IPC descriptor,writeonly on it and stderr. There is noopen,socket,connect,execve,fork,clonefor processes, and so on; a violation kills the process withSIGSYS. The allowlist has a few argument-checked entries:futexis 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);prlimit64only as a query (the new-limit pointer must be null, so limits cannot be changed);tgkillonly to the host’s own process id. On aarch64membarrieris 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 memorysysctlvalues. After applying it, the host checks that/etc/passwdcan no longer be opened. - Core files and file creation disabled, descriptor and CPU limits.
RLIMIT_ASdoes 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.
Windows
Section titled “Windows”- 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.
Same on every platform
Section titled “Same on every platform”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.
Crashes and restarts
Section titled “Crashes and restarts”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:
- 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; - does not restart the plugin immediately: the next start is delayed by 1 s, doubling with each consecutive failure up to 32 s;
- 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;
- 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.
Security guarantees
Section titled “Security guarantees”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 (
deniedotherwise). 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.
What not to rely on
Section titled “What not to rely on”- That the sandbox hides granted power. A permission such as
messages.sendornet.fetchdoes 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 forinitto 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
_infreshgram. Use the documented API andcompat.apiin 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.limitsand future runtime revisions may change them.
Testing locally
Section titled “Testing locally”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.
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 eventmsg new <chat> <text> [from=N] [out=true] add a message and deliver message.newmsg add <chat> <text> [from=N] [out=true] add a message the plugin can readchat add <id> <title> [type] add a chatcommand <name> [args] [chat=N] run a /command of the pluginmenu <surface> <id> [key=value ...] click a menu itemsettings|dialog|window|toast ... click/change/close UI of the pluginoutgoing <chat> <text> run the message.outgoing hookstimer advance <ms> move the clock so that timers firemock <url-prefix> <status> <body|@file> answer net.fetch callsgrant|revoke <permission> change granted permissionsstorage | info | wait <ms> | quitValues 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.
