Appearance
Security model
What a plugin can and cannot reach, and where the walls are. Read this before handling untrusted input, secrets, or other plugins' data — it tells you which guarantees you can lean on and which are your responsibility.
The guiding principle is fail closed: anything not explicitly declared and allowed is rejected.
Capabilities are the gate
Every privileged IPC action maps to a capability string. The runtime checks each incoming call against your manifest permissions before dispatching it; an undeclared call is rejected with CAPABILITY_DENIED and never reaches your handler. The manifest is the complete, auditable list of what a plugin can do.
- Scoped capabilities (
data.sql:self,http.fetch:api.example.com) only grant the named scope.http.fetchto a host you didn't declare is denied. - Wildcards are constrained at manifest-validation time:
data.read:*andevents.subscribe:*are rejected — you must name the target.
Full grammar and the capability list: Permissions.
Per-plugin data isolation
Each plugin gets its own SQLite database. There is no shared database and no cross-plugin write path — ever.
- Your
data.sql:selfoperates only on your database. - Another plugin can read declared parts of your data only through
data.read:<your-slug>.<table>, and only if you publish that table inpublic_schema. The read opens your database read-only and returns only the columns you listed.
public_schema is an allowlist, and internals are off-limits
A table is invisible to other plugins unless you put it in public_schema, and even then only its listed columns are readable. Two hard rules:
- Reserved tables can't be exposed. Table names beginning with
_(the runtime's internals —_configfor settings,_kv,_dlq) are rejected by manifest validation (RESERVED_PUBLIC_SCHEMA_TABLE). This is what stops a plugin from accidentally publishing its own settings — including secrets — to other plugins. - Only list what you mean to share. Columns outside the declared set are never returned, so omit anything sensitive.
Settings and secrets
Settings declared type: "secret" are redacted from the runtime's logs and diagnostics. They are not hidden from your own plugin — plugin.settings.get / getAll return the plaintext, because your plugin needs its own API keys to function.
So read "secret" as: sensitive, log-redacted, readable by this plugin, never exposed cross-plugin. Your responsibilities:
- Don't echo secret values into your own
console.log(that path isn't redacted) or into broadcasts/responses. - Don't store a secret in a column you publish via
public_schema(and you can't publish_configanyway — see above).
Outbound HTTP (plugin.fetch)
plugin.fetch is a guarded egress, not raw network access:
- Host allowlist — only hostnames you declared as
http.fetch:<host>. The request URL's hostname must match. - Scheme lock —
http:/https:only. - No redirects — responses are returned as-is (
redirect: "manual"); a30xdoesn't silently follow to a new host. - Header hygiene —
HostandCookieare always stripped;Authorizationis stripped on Central-targeted requests. - Bounds — 30 s timeout, 10 MB response cap.
- Public destinations only —
localhost,host.docker.internaland friends are rejected by name, and the hostname is resolved and classified before the request goes out. If any resolved address is non-public (RFC1918, loopback, link-local, CGNAT), the call fails withDESTINATION_BLOCKED. Empty resolution fails too. This is unconditional — there is no flag or opt-in.
Reaching a LAN or
localhostservice.plugin.fetchcannot do it, by design: a marketplace plugin must not be able to probe the owner's home network from an ad-hoc URL. The supported path is a reverse-proxy mount — declare it inproxy_mounts[]with anupstream_setting, and the owner supplies the address and approves it. Approved mounts do reach private addresses; the runtime pins the address class recorded at approval time and forces re-approval (PROXY_REAPPROVAL_REQUIRED) if it later drifts. So Home Assistant, a local game server, or any homelab upstream is a first-class use case — it just goes through owner-approved mounts rather than raw egress. See Reverse-proxy plugins.When auditing a plugin you install, the two things to read are its declared
http.fetch:<host>list and itsproxy_mounts[].
File storage is jailed
plugin.files.* operates only inside your plugin's uploads/ directory:
- Filenames are whitelisted (
[a-zA-Z0-9_.-], ≤255 chars);./..and path separators are rejected, and the resolved path must stay insideuploads/. - Signed URLs (
files.signUrl) are bound to a user id and time-limited (default 1 h, max 24 h). Mint them per read; don't hand out long-lived links.
Subprocess isolation
Your backend runs as its own subprocess with a minimal environment — only the variables the runtime sets (PLUGIN_SLUG, PLUGIN_DATA_DIR, PLUGIN_API_VERSION, NODE_OPTIONS, NODE_PATH, PATH/HOME, and the variable named by your manifest's data.dir_env if you declared one). The host's own environment is not inherited, so host and Central secrets never reach the plugin. The working directory is pinned to your plugin folder; stdin is owned by the IPC transport (don't read it).
On Linux the runtime spawns that subprocess inside a kernel sandbox (seccomp + Landlock) that enforces the capability model at the OS level, not just in the SDK. It is fail-closed: a Linux host that cannot establish the jail refuses to start the plugin rather than running it unsandboxed.
- Filesystem — a Landlock jail: your plugin folder is read+execute, your data directory is read+write but no-exec (so a plugin cannot drop a binary into its data dir and run it). It cannot read the server's config/secrets or another plugin's database off disk. (
plugin.files.*is still the supported way to store files.) - Network — the backend cannot open IP sockets.
AF_INET,AF_INET6,AF_PACKETandAF_NETLINKare refused atsocket()withEAFNOSUPPORT; onlyAF_UNIXstays open, for the runtime's own local transport. All outbound traffic goes throughplugin.fetchor a declared proxy mount — there is no rawfetch/node:netescape hatch. - Process —
NO_NEW_PRIVS, rlimits, and a denylist covering namespace, mount,ptraceand similar escape syscalls.
The rule of thumb: code against the SDK, not around it. A plugin already using plugin.fetch and plugin.files is unaffected; one reaching the network or filesystem directly will find those paths closed.
Frontend trust boundary
The panel iframe is sandboxed with an opaque origin and authenticated by an origin-verified handshake:
createPluginFrontend()derives the shell's origin and rejects any inboundpostMessagewhoseevent.origindoesn't match. Outbound messages always target that exact origin — never*.- File and proxy requests carry a per-session bearer token issued on handshake.
Because the origin is opaque, treat the frontend as untrusted for authorization. Never trust a user id, role, or permission decision that originates in the frontend — the backend's user argument (established by the runtime from the WebSocket session) is the only authority. Re-check every privileged action server-side in your handler.
Your responsibilities
The platform gives you isolation and gating; correctness inside your plugin is yours:
- Validate every
paramsfield in a handler — it'sunknownfor a reason. - Authorize in the backend, not the frontend (hide-the-button is UX, not security). Use
plugin.permissions. - Bind writes to
user.id, never to a client-supplied id. - Parameterize SQL — always pass values as
?params, never string-concat into the query.