Plugins may come from:
- The user's own development
- Shared by colleagues
- Third parties in a future marketplace
Main risks:
- Malicious file read/write
- Malicious command execution
- Stealing API keys / session content
- Hijacking agent tools
- Phishing via the UI
- Spending the user's model quota, or sending the conversation to another model (
agent.complete/session.read) - Triggering work in another durable session or spoofing its sender/provenance
- Undeclared permission = unavailable
- Disabled plugin = code not loaded
- Unconfirmed high-risk action = not executed
- Host API not on the allowlist = does not exist
pi.browser.cdpmethods not on the CDP allowlist =PERMISSION_DENIED(no cookies, storage, Target, or Fetch; no DevTools websocket)
- Plugin UI is isolated from the host UI DOM
- Plugins cannot directly require host modules
- The secret store is not open to plugins. Host-owned completions
(
agent.complete) resolve credentials in Electron main and never pass keys, refresh tokens, orModelAuthto the plugin process - The plugin-private data directory is separate from the host core library
- Session transcripts from
session.getLlmContextare a bounded projection of the in-flight tool session only (D336 / D019) - Session collaboration is available only through the reviewed
desktop.controlcatalog. The broker derives the source plugin, Session ID, turn ID, and invocation ID from the active Agent tool call; plugin payloads cannot provide those identities. Host-core owns the target Session ID, delivery ledger, permission ceiling, turn binding, callback, cancellation, and transcript provenance.
contributes.scenicThemes is data only. The host validates both grants,
same-plugin theme ownership, declared preview assets, and the exact bounded
--nexus-backdrop-blur variable before it renders cards in Extensions. A plugin
cannot supply Settings HTML, CSS, JavaScript, selectors, DOM, arbitrary actions,
or direct renderer IPC. The host owns the transparent canvas, layout, focus,
native controls, titlebar, Apply action, and lifecycle fallback to General.
Clipboard history is host-owned and remains in the Electron main process only.
It is never written to the plugin data directory or the host database. The host
records explicit clipboard writes and user-initiated Composer paste events; it
does not poll the OS clipboard in the background. A plugin can read history only
through clipboard.read, which is also the permission used by readText;
every getHistory call is audited with its returned entry count. The bounded
in-memory retention limits the privacy exposure to the current app run and is
cleared on exit.
The Session Orchestrator may create bounded worker sessions, address existing
Agent sessions, inspect bounded status/result projections, and cancel work when
the user grants desktop.control. This capability deliberately does not grant
the plugin direct session.create, agent.prompt, host RPC, SQLite, transcript
file, or MCP-token access. A send or spawn call must run inside the plugin's
currently executing Agent tool invocation; calls from a service, panel, or
ordinary plugin code without that context fail closed. A plugin panel may
request cancellation for that plugin's own deliveries as an explicit user
control, but cancellation cannot create or retarget a delivery.
Host-core snapshots the source permission ceiling and rejects targets above it, rechecks the target mode before beginning the turn, and enforces inbox, worker, and autonomous-hop limits. Existing target sessions retain their own project/model/context configuration. Completion callbacks are host-authored, at-most-once session messages and cannot authorize tools or trigger another callback. Restart recovery retains a durable queued delivery but never starts an interrupted turn unattended. Session-message provenance is immutable across transcript replacement and regeneration.
- Plugin main runs in a separate process
- Crash isolation
- Resource limits (later: CPU/memory/timeout)
A theme contribution (ui.theme) is the one case where plugin-authored content
runs inside the host renderer, so it crosses a sanitizer in the main process
before it is ever sent to the UI:
- Only CSS the browser applies is inspected: comment bodies and string literals
are blanked first, with one space per masked character so any offset still
points at the source, and each
url(...)argument is kept verbatim and judged by its target. A sheet that merely mentions a banned token in a comment or a string is therefore accepted - Rejected:
@import, anyurl()target that is neither adata:URI nor a declared theme asset, aurl(the parser cannot resolve,javascript:,expression(, and markup sequences (<style,</style,<!--); an empty sheet is refused too - Capped at 256KB per file, 8 themes per plugin
- A theme may declare
assetsusing whitelisted image/font extensions: either package-relative paths (resolved inside the plugin root; traversal andnode_modulesreferences are rejected) or absolute paths. The total is capped at 4MB. Each matchingurl()is rewritten toplugin-asset://<pluginId>/<path>and served read-only through the loaded plugin's registered list withnosniff; the registration is revoked when the plugin unloads.pi.themes.upsertmay register the same kind of path at runtime. An unregistered reference is refused, and the raw path never reaches the renderer contributes.windowAppearance(#rrggbb/#rrggbbaabackground and an integercornerRadiusof 0..24 DIP) requiresui.window.appearanceand applies only while one of that plugin's themes is the selected one; leaving the theme restores the host background, because the appearance is derived from the live catalog rather than remembered. macOS keepsvibrancyand its native corner behavior; Linux retains native corner behavior; Windows defaults to 4 DIP- The CSS is read from disk at load time and delivered whole over IPC; the
renderer injects it into a single dedicated
<style>element appended after the app's own stylesheets, so it can override tokens but never inject markup - Selecting a theme is a settings value (
plugin:<pluginId>:<themeId>); if the providing plugin is disabled or uninstalled the setting falls back tosystem
CSS cannot script, but it can mislead: a theme is still third-party code shaping what the user sees, which is why it is a declared, revocable permission.
At install/load time, show:
- Permission list
- Risk description
- Developer info
- Source path
User actions:
- Accept and enable
- Cancel
First use of a high-risk API may re-confirm.
Plugins can access:
- Their own settings
- Their own data path
Plugins cannot access:
- Other plugins' data
- Host secrets
- The host's full session database (unless a controlled API exists in the future)
The bus is the only channel between two plugins, and it is deliberately narrow:
- Both sides declare their traffic in the manifest —
bus.publishlists concrete topics,bus.subscribelists patterns — and the broker refuses anything undeclared even when the permission is granted - Routing lives entirely in the host; a subscriber never learns who else subscribes, and a publisher is excluded from its own fan-out
- A message carries only
topic,from,payload, and a host-assignedat - Caps: 64KB per payload, 16 subscriptions per plugin, 100 publishes per rolling
10s window; over-cap calls fail with
LIMIT_EXCEEDED/RATE_LIMITEDand are audited alongside the topic - A payload is data, not capability: receiving a message grants nothing the subscriber did not already have
Treat a topic as public within the app: any plugin that can declare a matching
pattern and hold bus.subscribe will see it. Do not put secrets on the bus.
fs.read / fs.write / fs.delete say whether a plugin may touch files;
manifest.fs says which ones (see
02-plugin-manifest-schema.md §5.2 and ADR 0088).
Every pi.fs.* call passes four gates in a fixed order, and a later gate can
only refuse:
- Permission — declared and granted. The runtime uses the intersection, so a permission the user revoked stops working even though the manifest still asks for it
- Containment —
realpathon both the root and the target, so a symlink inside the workspace pointing at~/.sshfails here rather than passing a string comparison. A path being created resolves through its nearest existing ancestor, so a new file is not indistinguishable from an escape. Absolute paths and..are refused; a path that merely does not exist is reportedNOT_FOUND, not as an escape - Deny-list, which overrides every root, scope, and grant:
- credentials:
.env*,.npmrc,.netrc,.pypirc,.git-credentials,id_rsa*and friends,*.pem,*.p12,*.pfx,*.keystore - directories, at any depth:
.git,.ssh,.aws,.gnupg,.kube,.docker - the host's own data directory, which holds provider keys and the session store
- the root itself, for a delete
- credentials:
- Declared scope, else a native confirmation (§6.2)
pi.fs.glob answers to the same rules — a name is a read, so denied paths and
reserved trees are omitted from the results, matches are filtered by the read
scope, and node_modules / .git / .venv / __pycache__ are never walked.
Deletion is the only file operation the user cannot recover by re-running the plugin, so it is bounded four ways:
- Two tiers. With
own: truethe host keeps a write ledger (path plus mtime) in the plugin's data directory and lets the plugin remove what it wrote itself, no scope and no prompt. If the file's mtime has moved past the recorded one, the user has edited it since and it is no longer the plugin's. Deleting anything else needs a declaredscope. - The OS trash. Removal goes through
shell.trashItem, neverrm, so a gate that got it wrong costs the user a restore rather than the file. The host copies none of the user's data to provide this. - Never recursive. A non-empty directory is refused rather than emptied.
- A rate brake. 50 removals per rolling 60s per plugin, because
recursive: falsebounds one call and not aglobfollowed by a loop. Past the brake the user is asked once, with the reason given as rate rather than path.
An access the manifest does not cover reaches a native dialog.showMessageBox:
Deny / Allow once / Allow this session. A session grant covers the
containing directory, is held in memory, and dies with the process; nothing is
persisted, and a rate-brake prompt is offered no session option at all. A host
with no consent service refuses — a host that cannot ask must never assume yes.
Both the denial and the grant are audited.
pi.fs.requestDirectory() opens the native directory picker; inside the returned
directory the plugin needs no manifest scope, because the user just pointed at
it. Containment and the deny-list still apply there. The handle is memory-only
and dies with the process, so the plugin holds unlimited reach and zero standing
power — the model the browser's File System Access API uses.
A sandboxed plugin panel may resolve a user-dropped File to a local path through
the host preload's getDroppedFilePath. The preload reports that path to the
panel host before page code runs. fs.registerDropped(path) consumes one of
those short-lived, sender-bound reports and returns a memory-only grantId.
The grant covers exactly that one canonical regular file for fs.stat and
fs.readRange; it does not change manifest.fs, grant a directory, or permit
writes, opens, reveals, or deletes. The grant dies with the plugin process and is
never persisted. Protected paths, credentials, symlink replacement, and the
deny-list remain enforced on registration and every subsequent read.
- Plugin tool names are namespaced to avoid collisions using the frozen forced prefix
plugin_<pluginIdSafe>_<toolName>(D015) - tool execution timeout
- tools can be disabled by the user in one click
- the prompt-injection API is high-risk by default and requires an explicit permission
Both surfaces let a plugin change what the agent knows or can do, so both are bounded before they reach the model:
Skills (agent.prompt.inject) — the system prompt carries only the catalog
(id, name, one-line description, capped at 240 chars); a body is read on demand
through the built-in Skill tool. A plugin may teach at most 32 skills, each
document at most 128KB. Without the permission the skills are simply skipped:
the manifest still validates, nothing reaches the prompt.
MCP tools (mcp.server.local / mcp.server.remote) — discovered tools are
registered under the same plugin_* namespace as hand-written plugin tools and
therefore inherit the tool timeout, the audit trail, and the per-plugin disable
switch. They are always registered at risk: "medium": their schema and
description come from a third-party server, so the host cannot trust a
self-declared risk level. A server's catalog is registered whole — the count is
bounded only by the protocol guards in §8.1 — while at most 8 servers per plugin
are admitted.
Plan is an additional host policy boundary for agent tools:
- no plugin tool is visible or executable in Plan;
- the deny precedes manifest risk, declared/granted permissions, session
grants, and the
autopermission mode; - a direct forged
tools.executecall returnsPLUGIN_DISABLED_IN_PLANand is audited; it is not forwarded to the plugin runtime; - plugin commands and panels may remain usable as explicit user UI actions, but they cannot become model-callable Plan tools or silently mutate Plan state.
net.fetchis not granted by defaultopenExternalshould confirm. The host parses the URL and opens onlyhttp:,https:, andmailto:(D330 / ADR 0168); other schemes fail withINVALID_ARGUMENTand never reachshell.openExternal.fs.openDefaultandfs.revealare separate: each accepts only an existing root-relative file that already passes the plugin'sfs.readpolicy. They are intended for explicit file-view actions, not arbitrary URL or absolute-path opening;fs.revealonly asks the OS file manager to select the file.- Plugins are forbidden from silently downloading and executing binaries (not done at all in MVP)
A permission cannot express "read broadly but leak nothing", so the range lives
in the manifest: net.domains is a single per-plugin hostname allowlist and every
outbound path the host owns answers to it.
- Panel sessions.
sandbox: trueremoves Node, not the network, so a panel was previously a full browser that never consultednet.fetch. The session now runs awebRequestfilter, refuses every device permission, and denieswindow.open, which would otherwise mint a window outside the filtered session pi.net.fetch. Checks the allowlist and follows redirects by hand, because an allowed host that 30x-es to an undeclared one would carry the request out. The runtime's hop loop is the only fetch path: Electron main supplies no alternativefetchservice, so nothing can follow a redirect without the per-hop re-check- Remote MCP endpoints. Answer to the same list, not to their permission alone. HTTP endpoints may be on a trusted LAN, but plain HTTP is unencrypted and is called out during configuration or plugin permission review. The MCP client follows redirects manually, allows at most five HTTP(S) hops, and re-checks the allowlist before every hop.
pi.net.websocket. Aws://orwss://target whose host is not inmanifest.net.domainsis refused at the egress chokepoint before the transport is asked to open anything, and the host — which owns the socket, not the plugin — closes every socket the plugin still holds when it unloads, is disabled, or crashes. Sockets are bounded per plugin (4), inbound and outbound frames are capped at 1 MiB, an oversized frame closes the connection instead of being buffered, and a send queue above 4 MiB is refused rather than grown. Frames are addressed to the owning plugin only.
An absent, empty, or malformed list means no egress at all, and a bare * is
refused at install so nobody declares their way out. This is what makes a
generous fs.read scope affordable (§6).
Still open, tracked separately: agent.prompt.inject (skill text can ask a
shell-capable agent to do the carrying), shell.openExternal, a bus.publish
relayed to a net-capable plugin, and raw fetch inside the plugin process — the
last one needs the sandboxed plugin runtime from ADR 0008 D009. pi.net.fetch
narrows none of that: the host applies the allowlist, follows redirects by hand,
and audits the call, but it never retries, throttles, or re-issues a request. An
upstream 429 reaches the plugin as 429 plus whatever Retry-After the server
sent, and what the plugin does about it is the plugin's own policy.
An MCP server is a second egress path next to net.fetch, so it is declarative
and reviewable rather than programmatic — a plugin cannot open a connection the
manifest did not name:
transport: "stdio"spawns a local executable (mcp.server.local). Thecommandmust be a bare PATH name or a plugin-relative path; absolute paths are refused at validation time. The child gets a minimal environment — the declaredenventries plus the shared allowlist (child-process-env.ts) and the extra profile/toolchain keysnpx/uvxneed (PATHEXT,ComSpec,FNM_DIR, …). Unix PATH is the login-shell PATH (D600). Barenpx/uvxresolve to real binaries; official Windows Node usesnode.exe+npx-cli.js, and remaining.cmdshims start throughcmd.exewith quoted literal args (D624). Provider keys and other host state still never cross.transport: "http"reaches a remote endpoint (mcp.server.remote). Theurlmay usehttporhttps; non-loopback HTTP is unencrypted and should only be used on a trusted network. Plugin endpoints must also be covered bymanifest.net.domains. Tool arguments leave the machine, which is why the permission copy says so plainly.envandheadersvalues resolve only from the plugin's own settings via{ "setting": "<key>" }. The host environment is never passed through, and a literal secret in the manifest is a review smell, not a supported pattern (D018).- Connection budget: 10s to complete
initialize, 100s pertools/call, 4MB per stdio line. Remote HTTP requests use the budget of the operation they carry, so a successful handshake does not impose its 10s limit on a later tool call.tools/listis followed to its last page under the per-server guards of §8.1 — 2048 tools, 100 pages, a cursor that repeats or is malformed, and 30s for the whole traversal — and a server that breaks one is refused rather than contributing a prefix of its catalog, because MCP tools reach the deferred on-demand entries behindToolSearch, not as an always-present list. Servers are connected lazily and torn down when the plugin unloads or is disabled. Stopping the calling session cancels that session's in-flight MCP request and sendsnotifications/cancelledto the server. A shared server connection and calls owned by other sessions remain active.
desktop.control hands a plugin the reviewed operation catalog the local MCP
control plane exposes (ADR 0203 / D370): project, session, Agent, and
workspace operations, each tagged read, write, or dangerous. The
plugin-only exception covers the six session/collaboration/* operations: they
are callable through the plugin gateway but deliberately absent from the
MCP-visible catalog, because they need an authenticated plugin invocation
context and no renderer mutation channel exists for them. The plugin sees ids,
descriptions, and risk, never Electron channel names or the MCP bearer token,
and every invocation crosses the same IPC validation, lifecycle checks,
completion event, and audit entry as an MCP call.
A dangerous operation is decided by the user, not by the caller. The
controller's confirm: true is only the plugin's acknowledgement (MCP treats
it the same way, D372). After it, the host shows a native dialog that names
the catalog operation id, the catalog description, and a bounded argument
preview, and it deliberately shows no text the plugin or a model behind it
authored, so a prompt-injected transcript cannot relabel session/delete as
something benign. Escape and dismissal are refusals. A headless host with no
dialog service refuses every dangerous operation outright.
ui.microphone allows only the media permission, for audio, inside the
plugin's isolated panel session. Camera and every other device permission stay
denied, and the plugin receives no native handle: capture stays page-owned.
audio.capture.background and audio.playback.background gate a callable
surface: the ten pi.audio.* methods exist in the plugin host process and keep
their permission requirement, but this branch has no device backend, so an
authorized call is refused with a coded UNSUPPORTED refusal that is audited
under audio.<method> with ok: false, and no device is opened (the two
synchronous registration helpers onInputFrame / offInputFrame throw the
same code instead of registering a handler that could never fire). When the
host service lands, the host owns the device: a plugin exchanges PCM16 frames
and never receives a MediaStream, a device handle, an OS device path, or a
Node stream, one input stream per plugin is allowed, and disable, unload,
crash, or permission revocation stops capture and drops queued playback
instead of leaving an orphaned device or timer.
keyboard.globalShortcut is implemented and stays inside the host's
registration model. The host owns Electron's globalShortcut; a plugin never
receives a keyboard hook, before-input-event, raw input device, or key event
stream, so there is no keylogger-shaped surface and no way to see the keys the
user types. A plugin may only map an accelerator to one of its own registered
commands, and an accelerator the OS reserves, that PI-Desktop itself currently
spends (the plugin-launcher and window-toggle bindings, Alt+Space and
Alt+Shift+W by default; a user rebinding one frees it for plugins), or that
another plugin holds is refused with
LIMIT_EXCEEDED (at most 8 per plugin) instead of being taken over. A trigger
runs exactly that one command. Register, unregister, and trigger are audited
with the plugin id and the result — a registration and a trigger also name the
accelerator and command — and typed input is never recorded. Every entry is
released on disable, unload, and crash.
Across all four capabilities, an undeclared or ungranted permission denies the call and is audited before any device, accelerator, or socket is reached.
Users should be able to:
- View plugin permissions
- View plugin error logs
- Disable in one click
- Uninstall in one click
The host should be able to:
- Auto-disable a plugin on anomaly
- Guarantee the main app can start
- Writing a file fails without the
fs.writepermission, and a write outsidemanifest.fs.write.scopeprompts the user - Deleting a file fails without the
fs.deletepermission, never recurses, lands in the OS trash, and is interrupted past 50 removals in a rolling minute - After disabling a plugin, its tools are no longer visible
- A plugin cannot read API keys
- A plugin panel cannot call arbitrary host IPC
- An uncaught exception from a plugin does not cause the app to exit
- A theme CSS file with
@importor a remoteurl()is refused, and disabling the providing plugin drops the app back to thesystemtheme - Publishing to an undeclared topic fails, and a publisher never receives its own message
- An MCP server declared with an absolute
commandfails manifest validation; a non-loopback plain-HTTP URL is accepted only when its host is declared inmanifest.net.domainsand the UI shows the unencrypted-connection warning - A low-risk or granted plugin tool still fails closed in Plan
Current enforcement:
- Default-deny permission checks in
PluginRuntime, over the intersection of declared and granted, so a revoked permission actually stops working - Symlink-safe containment plus an unconditional deny-list for plugin fs APIs,
with the reach of each file mode bounded by
manifest.fsand anything outside it falling to a native confirmation (§6) - Panel windows use sandboxed preload + isolated session partitions. Their
custom cross-platform titlebar is preload-owned, keeps its controls in a
closed Shadow DOM, and routes only a fixed sender-validated window-action
tuple without adding window primitives to
window.pluginBridge - Secrets / host DB remain inaccessible to plugins
- Marketplace/package install requires explicit permission acceptance in UI
- Auto-update refuses silent permission expansion
- Plugin main runs in a dedicated
utilityProcessper plugin (ADR 0008) with a minimal environment from the sharedchild-process-env.tsallowlist (PATH, toolchain dirs,HOME/USER/USERPROFILE; no provider keys); allpi.*calls cross an allowlist + permission gateway in the host, and a plugin crash only tears down that plugin - Contributed theme CSS is sanitized in the main process before it reaches the renderer (§3.1)
- Bus routing is host-owned with declared topics and hard caps (§5.1)
- MCP servers are declared, permission-gated, and fed credentials only from plugin settings (§8.1)
- Egress is confined to
manifest.net.domainsat every chokepoint the host owns (§8.0) - Plugin deletions go to the OS trash, are non-recursive, and are rate-braked (§6.1)
manifest.mainandui.panelare validated as relative paths at install and resolved with the same inside-the-plugin containment as skills and theme CSS before the host loads them- A
dangerousdesktop operation from a plugin needs the user's answer to a host-owned native dialog after the plugin's ownconfirm: true; the dialog shows only catalog text (§8.2) ui.microphonegrants audio capture only, inside the isolated panel session (§8.2)keyboard.globalShortcutis host-owned: the registry refuses an OS-reserved, host-owned, or other-plugin accelerator, a shortcut can only run the owning plugin's own command, and every entry dies on the same teardown path as the plugin's commands and tools (§8.2)
audio.capture.background and audio.playback.background are declared and
present in the plugin API: the methods are gated by those permissions and an
authorized call is refused with a coded UNSUPPORTED refusal that is audited,
because this host has no device backend yet, so nothing reaches a device.
net.websocket is implemented: connections are host-owned, allowlist-checked,
bounded, and released with the plugin (§8.1).
Not enforced yet:
- Capability sandboxing inside the plugin process (Node built-ins are reachable
there, so
fs.*permissions gate the plugin API, not the process). This is the remaining gap that matters: everything in §6 and §8.0 bounds a plugin using the API it is supposed to use, not one that bypasses it (ADR 0008 D009) - CPU / memory limits
- Signature verification (packages are only sha256-checked)
- Declared manifest permissions are auto-granted at load time, subject to the user unchecking them at install
- A
userSelectedroot does not survive a restart, so a plugin has to ask again each session