04. Plugin Security
1. Threat model
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
2. Default-deny principle
- 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)
3. Isolation strategy
Must
- 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.
Host-rendered scenic Settings destinations
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.
3.2 Session collaboration boundary
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.
Goals
- Plugin main runs in a separate process
- Crash isolation
- Resource limits (later: CPU/memory/timeout)
3.1 Contributed theme CSS
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 not adata:URI, 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
assets(absolute paths, whitelisted image and font extensions, 4MB summed). Each matchingurl()is rewritten toplugin-asset://<pluginId>/<path>and served by a host handler that resolves only through the loaded plugin's own registered list: read-only,nosniff, and revoked when the plugin unloads.pi.themes.upsertregisters the same kind of path at runtime. An unregistered reference is still refused, and the raw path never reaches the renderer contributes.windowAppearance(#rrggbb/#rrggbbaa) 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 colour is derived from the live catalog rather than remembered. macOS keepsvibrancyand is never sent one- 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.
4. Permission-grant UX
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.
5. Data isolation
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)
5.1 Inter-plugin message bus
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.
6. Path safety
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.
6.1 Deletion
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.
6.2 Runtime consent
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.
6.3 The user-selected root
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.
6.4 Dropped-file grants
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.
7. Agent security
- 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
7.1 Skills and MCP tools reaching the agent
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.
8. Network and external links
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)
8.0 Egress allowlist
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.
8.1 MCP server egress and credentials
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 one shared allowlist (child-process-env.ts):PATH,SystemRoot,windir,TEMP,TMP,TMPDIR,LANG,HOME,USER,USERPROFILE. The identity variables are there because the child is third-party code that resolves~through$HOMErather than callingos.homedir()(issue #717); 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.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.
8.2 Desktop control and device access
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.
9. Auditing and emergency response
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
10. Security acceptance
- 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
11. Implementation status
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