diff --git a/lib/plugin-sandbox/host-api.ts b/lib/plugin-sandbox/host-api.ts index cd082de6..9e9cba21 100644 --- a/lib/plugin-sandbox/host-api.ts +++ b/lib/plugin-sandbox/host-api.ts @@ -99,7 +99,12 @@ const PERM_PER_METHOD: Record = { 'sieve.regenerate': 'filters:write', }; -function hasPermission(plugin: InstalledPlugin, perm: Permission): boolean { +/** + * Single source of truth for "may this plugin use `perm`?". Exported so the + * loader can gate hook registration with the same rule the RPC layer uses - + * two copies of this logic would drift. + */ +export function hasPermission(plugin: InstalledPlugin, perm: Permission): boolean { if ((IMPLICIT_PERMISSIONS as readonly string[]).includes(perm)) return true; if (!plugin.permissions.includes(perm)) return false; // Defense-in-depth: even if the manifest declares a permission, the host diff --git a/lib/plugin-sandbox/loader.ts b/lib/plugin-sandbox/loader.ts index a22d053c..d7826c1c 100644 --- a/lib/plugin-sandbox/loader.ts +++ b/lib/plugin-sandbox/loader.ts @@ -18,8 +18,9 @@ import { verifyBundle } from './bundle-integrity'; import { createBackgroundInstance } from './host-bridge'; import { resolvePluginTier } from './tier'; import { register as registerActive, deregister as deregisterActive, all as allActiveEntries } from './registry'; -import { cancelPluginDialogs } from './host-api'; +import { cancelPluginDialogs, hasPermission } from './host-api'; import { registerShortcuts } from './shortcuts'; +import type { Permission } from '../plugin-types'; // ─── Hook-bus lookup (one flat map for name → bus) ──────────── @@ -35,6 +36,38 @@ const HOOK_BUSES: Record = Object.assign({}, messageListTabHooks, ) as Record; +// ─── Permission-gated hooks ─────────────────────────────────── +// +// `info.hooks` is SELF-REPORTED by the sandboxed bundle, so registration must +// be checked against granted permissions - otherwise any untrusted plugin could +// claim a sensitive hook simply by naming it. Consent-dialog copy is not a +// substitute: it gates what the user was *asked*, not what the host *allows*. +// +// Listed here are the hooks that can read message content, alter outgoing mail, +// or observe key state. Hooks absent from this map are unrestricted (UI +// observation, navigation, toasts and similar) and register as before. +const HOOK_PERMISSIONS: Record = { + // Render takeover - replaces the rendered body the user sees. + onRenderEmailBody: 'email:render-takeover', + onEmailListItemRender: 'email:read', + onEmailContentRender: 'email:read', + // Outgoing-mail interception: veto, mutate, or take over the send entirely. + onComposeSend: 'email:send', + onBeforeEmailSend: 'email:send', + onTransformOutgoingEmail: 'email:send', + // Bulk message content reaching the plugin. + onEmailsFetched: 'email:read', + onProvideSearchResults: 'email:read', + // Attachment bytes on the way up. + onBeforeBlobUpload: 'email:blob-write', + onBeforeAttachmentUpload: 'email:blob-write', + // S/MIME key + certificate state. + onSmimeKeyImport: 'smime:read', + onSmimeCertImport: 'smime:read', + onSmimeKeyStateChange: 'smime:read', + onSmimeDefaultsChange: 'smime:read', +}; + // ─── Store accessor (status updates flow through the existing store) ── type StoreAccessor = { setPluginStatus: (id: string, status: InstalledPlugin['status'], error?: string) => void }; @@ -128,6 +161,7 @@ export async function loadSandboxedPlugin(plugin: InstalledPlugin): Promise` hooks // are dispatched by the keyboard module separately and don't have a bus. const hookDisposables: Disposable[] = []; + const refusedHooks: string[] = []; for (const hookName of info.hooks) { if (hookName.startsWith('shortcut:')) continue; const bus = HOOK_BUSES[hookName]; @@ -135,6 +169,17 @@ export async function loadSandboxedPlugin(plugin: InstalledPlugin): Promise { try { return await bg.invokeHook(hookName, args); @@ -160,7 +205,15 @@ export async function loadSandboxedPlugin(plugin: InstalledPlugin): Promise 0 ? `, refused=${refusedHooks.length}` : ''})`, + ); + if (refusedHooks.length > 0) { + console.warn( + `[plugin-sandbox] "${plugin.id}" ran without ${refusedHooks.length} hook(s): ${refusedHooks.join(', ')}`, + ); + } } catch (err) { const msg = (err as Error).message ?? String(err); storeAccessor?.setPluginStatus(plugin.id, 'error', msg);