01. Security
Language: English (per ADR 0009). Statuses reflect the implementation as of M5 hardening. Cross-references: logging · process model · plugin security
1. Security goals
- The renderer must never gain unconstrained system access
- Protect provider API keys
- Bound the blast radius of agent tool execution
- Keep sensitive operations auditable
2. Electron baseline
Required (all implemented):
contextIsolation: truenodeIntegration: falsesandbox: true— the preload is a fully bundled CJS file with no runtime module resolution, verified end-to-end bytest:e2e:boot- No remote module (Electron ≥ 14 default)
- Navigation locked down:
setWindowOpenHandlerdenies in-app windows and forwards only parsedhttp:/https:/mailto:URLs to the OS browser (parseAllowedExternalUrl, D330 / ADR 0168).file:,javascript:,data:, and custom URI schemes never reachshell.openExternal.will-navigateblocks all non-dev-server navigations - Every web contents Electron creates starts with a deny-all window-open handler and a blocked
<webview>attach (app.on("web-contents-created")); the owning surface replaces the handler with its own policy, so a window that forgets to wire one denies popups instead of inheriting Chromium's defaults - Preload exposes a whitelist-checked
invoke/onbridge only (IPC_WHITELISTenforced on both preload and main sides) - Transcript Markdown is sanitized (
rehype-sanitize), but remotehttp(s)images, audio, and video that a model writes into a reply are fetched on render, without a click. This is a deliberate readability trade-off: a reply can therefore reveal the user's IP to the host it names. Links never navigate in-app and always route through the external-open path.
Content Security Policy
- Dev:
script-src 'self' 'unsafe-inline' 'unsafe-eval'(required by Vite HMR tooling), localhost websocket connect-src. Fonts are restricted to'self'anddata:so Vite-inlined KaTeX WOFF2 assets can render without admitting remote font origins. - Production build:
'unsafe-eval'and localhost connect-src are stripped at build time (tightenCspplugin inelectron.vite.config.ts);connect-src 'self'only. Provider network traffic happens in the Node sidecar, never in the renderer. - Assistant Mermaid diagrams do not widen CSP or renderer privileges. Only a completed answer fence may dynamically load the bundled local renderer. Mermaid runs with
securityLevel: strict, protected security/theme/limit configuration, HTML labels disabled, a 20,000-character source limit, and a 500-edge limit. Its generated SVG then passes through DOMPurify's SVG profile; links, URL attributes,foreignObject, script, embedded media, and external image elements are removed before the SVG reaches the DOM. Invalid or oversized input fails closed to escaped source text.
Future hardening (tracked, post-MVP)
- Electron fuses (
runAsNode,nodeCliInspectoff) at package time webSecurityassertions in an automated security e2e
3. Secrets
- Keys stored via Electron
safeStorageencryption, managed by host-core (see 14-secrets-storage) - UI shows configured/not-configured only; never echoes key material
- Logs must never contain secrets: Logger redaction (key-name patterns +
sk--style token pattern) in Electron main,redact_valuein host-core audit writes; verified by the no-secret-leak smoke check - Error messages must not echo full keys
4. Workspace sandbox
- File tools are restricted to the project root by default; an explicit path outside the session workspace and scratch roots requires the host permission decision described in
03-runtime/03-tools-and-permissions.md - Path normalization + root boundary check in host-core (
workspace::tests::blocks_escapecovers escape attempts) - Symlink targets outside the root are rejected when detectable unless the explicit path was approved by the host permission layer
Plan is not itself the workspace security boundary. Host-core resolves the durable session mode for every tools.execute call and applies the Plan matrix before permission modes, grants, plugin risk, or renderer/sidecar state. Plan denies Write/Edit/plugin/unknown tools, while BrowserPreview is the explicit read-only UI inspection exception (it reveals bundled pi.browser chrome; raw CDP plugin tools stay denied in Plan). Bash remains available in Plan: Ask and Accept edits prompt, and Auto runs without confirmation and may mutate the workspace or scratch directory. The UI must state this tradeoff. SubmitPlan preserves exact Markdown bytes in a new unique <workspaceRoot>/.pi/plan/*.md file through host-core, validates the in-root artifact path, computes SHA-256 and byte size, and only then creates the plan_approvals record with structured title/question fields. Renderer and sidecar state cannot write or replace an artifact.
4.1 Skill market egress
The renderer does not fetch skill catalogs or SKILL.md documents. Electron main performs those HTTPS requests under the public-network policy (ADR 0243 / D413, amended by ADR 0272 / D436): https only, a shared syntactic public-host check, redirect: "manual", and a per-hop verdict that follows the route the request will actually take. Before each hop the client asks the session that carries net.fetch for its own proxy decision (Session.resolveProxy): on a proxied route the hop is judged on its route rather than on a local address the app would never dial, so only the resolver-artifact class (benchmark, a TUN fake-IP) is tolerated there, while a direct or unreadable route keeps the full local classification and rejects loopback, RFC1918, ULA, link-local, mapped IPv6, and every other non-public class by default. The explicit allowFakeIp setting may additionally permit only the benchmark placeholder for a transparent router/TUN deployment. Install writes markdown only through skills.create. The host document cap remains 128 KiB after sibling markdown is inlined.
A source URL the user typed is judged by ADR 0304 instead: it may be a loopback or LAN catalog, and plain http to it is allowed because the relaxed network mode is on by default (networkPolicy.mode). Every document URL that arrives inside a catalog, and every redirect target, keeps the public-only policy above, in either mode.
4.2 MCP market egress
The MCP market accepts only credentials-free public HTTPS sources and catalog endpoints. Main asks the same Electron session that carries the request for its proxy route before every hop. On a fully proxied route, it uses Chromium net.fetch, which lets system/PAC and custom proxies resolve fake-IP names; the local resolver's benchmark fake-IP class is tolerated there, while real private and other non-public classes remain rejected. On direct or unknown routes, Main keeps the existing Node HTTPS path and pins the selected public address to the socket, retaining the original host for TLS SNI and HTTP Host. The explicit allowFakeIp setting may additionally permit only benchmark answers on those routes; it never permits other non-public classes. Redirects are manual, HTTPS-only, limited to five hops, and checked again before each connection. connection. Responses are capped at 4 MiB, requests share an 8-second deadline, and source/cache/entry counts are bounded. Cross-origin user-MCP redirects do not forward caller headers.
Manual user-owned MCP configuration remains covered by ADR 0142 and may use explicit local/LAN endpoints; the market path does not widen that policy.
A market source URL the user typed is judged by ADR 0304 as well: it may be a loopback or LAN endpoint, with plain http behind the relaxed network mode (networkPolicy.mode, on by default). Everything a source returns — registry records, catalog bodies, redirect targets — keeps the public-only policy above.
4.3 Portable configuration sync
WebDAV sync is a host-core network boundary. The renderer and Agent Runtime cannot access the endpoint, WebDAV password, backup password, vault key, or portable secret values. Host-core validates the selected HTTPS endpoint, rejects userinfo and redirects, constrains relative paths, bounds remote object size and KDF parameters, and requires strong conditional-write behavior before publishing a shared head in strict mode. An explicitly confirmed append-only compatibility mode may be used after a bounded PROPFIND directory-listing probe succeeds; it publishes per-device encrypted pointers and retains immutable history rather than pretending an unconditional PUT is CAS.
Every remote payload is authenticated ciphertext. The WebDAV server receives neither the vault password nor the local machine encryption key. Credentials are exported only after explicit category opt-in and are never included in status, preview, conflict labels, or logs. Restored provider/MCP secrets are written through the host secret store; OAuth sessions and cookies are never portable.
Imported commands, endpoints, scripts, skills, plugins, and automations are staged behind a digest-bound local approval. Local paths and approvals are overlays, not shared entities. A new device therefore cannot execute a synchronized capability merely because its desired enabled flag was imported. The compatibility-mode warning states that all devices sharing a vault must use the same mode and that concurrent changes can still require review. It does not weaken approval, secret export, redirect, path, object-size, or freshness protections. The server can still deny availability or replay a valid old head to a fresh device that has no trusted history; sync does not claim availability or freshness against a malicious server.
5. Command execution
- Bash requires confirmation by default (risk-tiered permission cards); in either Agent or Plan, explicit Auto may run it without confirmation
- The Bash protocol name remains stable, but host-core selects a catalog shell (
windows-powershell,windows-pwsh,cmd,git-bash, orbash) from persisteddefaultCommandShellwhere supported by the platform. Settings writes reject unavailable/wrong-platform IDs. If a persisted choice later becomes unavailable, catalog resolution intentionally falls back to the first available platform shell; each turn pins its effective ID/dialect and the host rejects a changed pin before spawn withCOMMAND_SHELL_CHANGED. - Timeouts are mandatory: 60s default with a 1–21,600s bounded override (D329). Output streams as separate stdout/stderr channels and is truncated at 96KB / 4000 lines with an explicit
[truncated: …]marker that names which end survived (see 16-tool-result-limits) - User abort and timeout shut down the complete process tree before the tool closes; no orphan process may continue writing output.
- Full command line recorded in the audit log (SQLite, redacted), with shell ID and dialect rather than an untrusted executable path or path hash
- Allowlist/denylist refinement is a tracked follow-up (03-tools-and-permissions)
6. Supply chain
- Dependency versions locked via
pnpm-lock.yaml/Cargo.lockcommitted to the repo; upgrades are explicit commits - Prefer official pi packages
- Marketplace package installation is remote-capable. Current SHA-256 checks verify transfer integrity against the catalog value, but signatures and publisher provenance are not yet enforced; see the plugin trust limitation in
07-plugins/08-plugin-signing-updates.mdbefore treating marketplace code as trusted. - A plugin main currently runs with raw Node built-ins in its own
utilityProcess. The brokeredpi.*permission gate does not constrain direct Node access, so marketplace plugins must be treated as unrestricted user-privileged code until capability sandboxing is implemented.
7. Application update security (D120)
- Electron Main owns
electron-updater; renderer IPC cannot provide or override the fixed HTTPS GitHub owner/repository or releases URL. - The updater forces
allowPrerelease = falseso discovery always uses GitHub's latest stable release rather than a same-channel prerelease pin. - Feed manifests bind artifacts with electron-builder hashes. An error, unavailable feed, hash mismatch, or invalid updater state must not install.
- Packaged macOS, Windows NSIS, and Linux AppImage download and install in-app from the GitHub Releases feed. Linux deb/rpm and Windows ZIP detect a release and open the fixed releases page. Legacy Windows portable executables remain manual when
PORTABLE_EXECUTABLE_FILEis present. - D126 tag releases publish Windows NSIS and Linux AppImage installers with their update manifests, plus Linux deb/rpm packages and a Windows portable ZIP. The NSIS and AppImage artifacts activate the existing in-app lanes. The portable ZIP uses notify-and-link delivery and does not write
latest.yml. macOS tag artifacts are Developer ID-signed, notarized, and stapled before upload; rollback and staged-rollout qualification remain release follow-ups. - The client carries no GitHub token. A private or otherwise unreachable feed fails closed; automatic failures stay ambient and explicit checks expose the error.
- Unsigned macOS distributions keep a narrow first-launch fallback for trusted sources. The DMG is a two-icon install and does not include that note. The ZIP package includes a text note and the executable helper, which searches only
/Applications/PI-Desktop.appand~/Applications/PI-Desktop.app, verifiesCFBundleIdentifierisnet.aiuo.pi-desktop, removes onlycom.apple.quarantinerecursively when present, and opens the app. It accepts no arbitrary path, uses no privilege escalation, and is not a substitute for Developer ID signing or notarization. The note gives the manualcom.apple.quarantinecommand and says signed/notarized builds do not need it. - Localized product "what's new" text (D164/D345) is selected in Main from the shipped changelog catalog and attached to
UpdateState.releaseNotes. The renderer cannot supply a notes URL, feed, or remote body; missing catalog entries simply omit the section. - The Developer ID + notarization lane remains documented in the release runbook.
8. Local MCP control plane
The local MCP control server is an explicit automation boundary, not a general remote-control listener:
- It is disabled by default and only starts with
PI_DESKTOP_MCP_CONTROL=1. - It binds
127.0.0.1only and refuses to start if the listen address is not loopback. There is no configuration path for a LAN or public interface, and the feature does not revive the deferred remote Gateway / WebUI scope. - It validates any supplied
Originagainst local loopback hostnames to block DNS-rebinding access from remote web content. Non-browser clients may omitOrigin. - A random 256-bit bearer token is persisted in the Electron user-data directory. The token and connection manifest are mode
0600where supported, and the manifest is marked inactive during shutdown. - Secret get/set/delete channels, provider/OAuth/MCP secret-write paths, settings writes, and renderer-only native picker/dialog channels (including
plugin/loadDev) are excluded. Secret-shaped argument fields are stripped before dispatch. The reviewed catalog is explicit; newly added IPC handlers are not exposed automatically. - Calls delegate to the existing main-process IPC handlers, so host availability, workspace boundaries, permission checks, input validation, and redacted logging remain authoritative. Dangerous generic operations, session-configure (permission mode), and destructive named tools require an explicit
confirm: true. That flag is an agent acknowledgement, not a user prompt. - Request and serialized-result sizes, including
structuredContent, are bounded. Startup failure is fail-soft for the desktop.
An Agent using this endpoint has the same local-user authority as the running desktop for the operations it invokes. Users must protect the user-data directory and token; the endpoint is not intended for untrusted local users or remote clients. confirm: true does not ask the visible desktop for approval.
The post-MVP remote control target is a separate security boundary. It is specified in 02-remote-control-security.md and 02-architecture/05-remote-agent-control.md. Those specifications require an authenticated Gateway/Agent Host link, session-scoped authorization, event replay controls, and no network access to host-core. They do not change the loopback-only rule above.
9. Host process attack surface
- host-core speaks NDJSON JSON-RPC on stdio to the Electron main process only; it binds no network ports
- The agent sidecar reaches host services only through the main-process proxy (
host.proxy), which enforces a method allowlist (tools.execute,tools.list,session.get,session.appendMessage,workspace.get,app.health) — the sidecar cannot pull secrets or mutate providers/settings/plugins through the proxy - host-core child processes (Bash tool) run with the user's privileges; containment relies on the permission layer, catalog identity, process-group/ job-tree shutdown, and workspace sandbox rather than OS sandboxing
10. Threat model (summary)
| Threat | Mitigation |
|---|---|
| Malicious web content in renderer | no Node, sandbox, navigation lock, CSP |
| Prompt-injected destructive tool use | host-owned durable mode policy, permission confirmation, path boundary, secret isolation |
| Dependency poisoning | lockfiles, few deps, native-module review |
| Malicious local plugin | declared permissions, no secret access, process isolation tracked post-MVP (ADR 0008) |
| Skill market SSRF via user source URL | public-HTTPS classifier + DNS + per-hop redirect checks in main; renderer CSP forbids the fetch (ADR 0243) |
11. Security acceptance gates
- Renderer cannot
require('fs')(sandbox + no nodeIntegration) — verified - Plan Write/Edit/plugin calls cannot run under any permission mode; Bash is confirmed under Ask/Accept edits and may run without confirmation only under explicit Auto
- Writing outside the workspace fails — verified (host tests)
- API keys never appear in plaintext in exports/logs by default — verified
- Non-whitelisted IPC channels are rejected — verified (M1)
- A forged renderer/sidecar mode cannot override the durable host session mode
- Plan artifact bytes/path/hash/size are host-authenticated; approval is approve/reject-only and scheduled Plan is rejected before artifact/queue work
- Plan expiry, rejection, host restart, and stale responses never replay pending/queued/running work; an approved interruption leaves the session Agent
- Invalid settings and stale shell ID/dialect fail closed; Bash output streams separately and timeout/abort kills the complete process tree
- Local MCP control is loopback-only, bearer-authenticated, opt-in, bounded, excludes secret writes and native pickers, and requires confirmation for session permission-mode changes
12. Native Pi session boundary (ADR 0254)
Native session paths remain sidecar-private. Renderer-visible ids are opaque hashes of canonical path plus verified header id. Every discovery/open resolves the real path below the configured Pi session root and revalidates header id and cwd; path traversal and symlink escape are rejected.
Writable continuation requires a mode-0600 cooperative PI-Desktop lease beside the session and full-byte identity checks before each SDK append. After an append, the adapter accepts only the unchanged prior prefix plus exactly one entry whose id and parent match the SDK operation. Any foreign/interleaved change disposes the runtime and requires reload. A stale lease is reclaimed only for a provably dead process on the same host when the target is unchanged or is a complete same-file append-only extension with the original byte prefix and a continuous parent chain. This lease is not treated as proof that Pi Web/CLI is absent because those clients do not yet share its protocol.
Native continuation passes noTools: "all" to the SDK: no built-in or extension model tools are exposed, including filesystem/shell tools. permissionMode: "inherit" is not a permission bridge. Enabling native tools requires an explicit Desktop permission integration and updated security decision. Native Pi extensions still execute as trusted local code with the native resource lifecycle; they are not Desktop plugins and this is not a sandbox claim. Capability checks recognize this service's owned lease and reclaimable dead local owners without stealing live, remote, malformed, or uncertain leases. Native fork reuses the same source ownership gate: an owned idle runtime keeps its lease, an unowned source is held under a short-lived lease for the snapshot window, and a live/remote/malformed foreign lease or a changed source refuses the fork. The child is written as a private mode-0600 non-jsonl staging file in the parent's session directory (fsync, then a no-clobber hardlink to the final name); cleanup removes only files whose device/inode and content still match what this operation created, and unexpected filesystem failures cross the preload boundary only as a path-free classified error.