diff --git a/apps/desktop/src/components/plugins/PluginContributionsPanel.vue b/apps/desktop/src/components/plugins/PluginContributionsPanel.vue
index 21b1a4fe3a..8c91592f2e 100644
--- a/apps/desktop/src/components/plugins/PluginContributionsPanel.vue
+++ b/apps/desktop/src/components/plugins/PluginContributionsPanel.vue
@@ -2,6 +2,7 @@
import { computed, defineComponent, h, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { ArrowUp, BadgeCheck, Check, ChevronRight, CircleAlert, Download, ExternalLink, FileUp, FolderTree, Globe, Info, LayoutGrid, Link2, List, Loader2, PackageCheck, Pencil, Pin, PinOff, Plus, RefreshCw, RotateCcw, Search, Settings2, ShieldCheck, Store, Trash2 } from "@lucide/vue";
import { Badge } from "@/components/ui/badge";
+import { isSensitivePluginPermission } from "@/lib/plugins/pluginPermissions";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -1039,7 +1040,16 @@ onBeforeUnmount(() => {
{{ tag }}
- {{ t("pluginPlatform.permissionsCount", { count: listing.plugin.permissions.length }) }}
+
+
+
+ {{ listing.plugin.permissions.join(" · ") }}
+
+ {{ listing.plugin.permissions.join("\n") }}
+
+ {{ permission }}
diff --git a/apps/desktop/src/components/plugins/PluginWorkbenchHost.vue b/apps/desktop/src/components/plugins/PluginWorkbenchHost.vue
index e2bb5d71a9..a260fcfd7b 100644
--- a/apps/desktop/src/components/plugins/PluginWorkbenchHost.vue
+++ b/apps/desktop/src/components/plugins/PluginWorkbenchHost.vue
@@ -3,7 +3,7 @@ import { computed, inject, nextTick, onBeforeUnmount, onMounted, ref, watch } fr
import { AlertTriangle, Loader2 } from "@lucide/vue";
import * as api from "@/lib/backend/api";
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
-import { copyToClipboard } from "@/lib/common/clipboard";
+import { copyToClipboard, readTextFromClipboard } from "@/lib/common/clipboard";
import {
PluginHostBridge,
pluginSandboxDocument,
@@ -404,6 +404,15 @@ function createBridge() {
downloadFile: isTauriRuntime() ? downloadPluginFile : undefined,
cancelDownload: isTauriRuntime() ? cancelPluginDownload : undefined,
copyText: (_pluginId, text) => copyToClipboard(text),
+ // Permission-gated in the bridge (host.clipboard:read); the helper
+ // prefers the Tauri clipboard plugin and falls back to the Web Clipboard.
+ clipboardRead: (_pluginId) => readTextFromClipboard(),
+ // Session consent for the first clipboard read: a native ask dialog naming
+ // the plugin, so reads always have a human in the loop. On the web host
+ // (no dialog surface) the callback is omitted and the bridge denies.
+ confirmClipboardRead: isTauriRuntime()
+ ? (_pluginId, pluginName) => import("@tauri-apps/plugin-dialog").then(({ ask }) => ask(t("pluginPlatform.clipboardReadConsent", { name: pluginName }), { title: t("pluginPlatform.clipboardReadConsentTitle"), kind: "warning" }).then((allowed) => allowed === true))
+ : undefined,
pickFiles: (pluginId, options) => pickPluginFiles(pluginId, options),
readFileChunk: (pluginId, handleId, offset, length) => readPluginFileChunkById(pluginId, handleId, offset, length),
beginFileSave: (pluginId, request) => beginPluginFileSave(pluginId, request),
diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts
index d54a98789d..82ff431734 100644
--- a/apps/desktop/src/i18n/locales/en.ts
+++ b/apps/desktop/src/i18n/locales/en.ts
@@ -182,6 +182,8 @@ export default {
verified: "Verified",
noDescription: "No description provided.",
permissionsCount: "{count} permissions",
+ clipboardReadConsent: '"{name}" wants to read the system clipboard. Its workbench will receive the current clipboard content (possibly passwords or tokens you copied). Allow clipboard reads for this session?',
+ clipboardReadConsentTitle: "Clipboard Read Request",
unsupportedTarget: "Not available for {target}",
installedVersion: "Installed v{version}",
installedVersionUpdatable: "Installed v{installed} · Update to v{latest}",
diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts
index e637106642..5d2716c363 100644
--- a/apps/desktop/src/i18n/locales/es.ts
+++ b/apps/desktop/src/i18n/locales/es.ts
@@ -185,6 +185,8 @@ export default withEnglishFallback({
verified: "Verificado",
noDescription: "Sin descripción.",
permissionsCount: "{count} permisos",
+ clipboardReadConsent: '"{name}" solicita leer el portapapeles del sistema; su zona de trabajo recibirá el contenido actual del portapapeles (posiblemente contraseñas o tokens que hayas copiado). ¿Permitir la lectura del portapapeles en esta sesión?',
+ clipboardReadConsentTitle: "Solicitud de lectura del portapapeles",
unsupportedTarget: "No disponible para {target}",
installedVersion: "Instalado v{version}",
installedVersionUpdatable: "Instalado v{installed} · Actualizar a v{latest}",
diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts
index a3f64ae17a..20cfb8ceba 100644
--- a/apps/desktop/src/i18n/locales/it.ts
+++ b/apps/desktop/src/i18n/locales/it.ts
@@ -184,6 +184,8 @@ export default withEnglishFallback({
verified: "Verificato",
noDescription: "Nessuna descrizione.",
permissionsCount: "{count} autorizzazioni",
+ clipboardReadConsent: '"{name}" chiede di leggere gli appunti di sistema: la sua area di lavoro riceverà il contenuto attuale degli appunti (eventualmente password o token copiati). Consentire la lettura per questa sessione?',
+ clipboardReadConsentTitle: "Richiesta di lettura degli appunti",
unsupportedTarget: "Non disponibile per {target}",
installedVersion: "Installato v{version}",
installedVersionUpdatable: "Installato v{installed} · Aggiorna a v{latest}",
diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts
index 8b0197c902..58dd9b87a1 100644
--- a/apps/desktop/src/i18n/locales/ja.ts
+++ b/apps/desktop/src/i18n/locales/ja.ts
@@ -185,6 +185,8 @@ export default withEnglishFallback({
verified: "認証済み",
noDescription: "説明はありません。",
permissionsCount: "権限 {count} 件",
+ clipboardReadConsent: "「{name}」がシステムクリップボードの読み取りを要求しています。そのワークベンチは現在のクリップボードの内容(コピーしたパスワードやトークンを含む可能性があります)を受け取ります。このセッションで読み取りを許可しますか?",
+ clipboardReadConsentTitle: "クリップボード読み取りの要求",
unsupportedTarget: "{target} では利用できません",
installedVersion: "インストール済み v{version}",
installedVersionUpdatable: "インストール済み v{installed} · v{latest} に更新可能",
diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts
index 90c9db6e0b..08b8743b9c 100644
--- a/apps/desktop/src/i18n/locales/ko.ts
+++ b/apps/desktop/src/i18n/locales/ko.ts
@@ -184,6 +184,8 @@ export default withEnglishFallback({
verified: "검증됨",
noDescription: "설명이 제공되지 않았습니다.",
permissionsCount: "권한 {count}개",
+ clipboardReadConsent: '"{name}" 플러그인이 시스템 클립보드 읽기를 요청합니다. 해당 워크벤치는 현재 클립보드 내용(복사한 비밀번호나 토큰이 포함될 수 있음)을 받게 됩니다. 이 세션에서 읽기를 허용하시겠습니까?',
+ clipboardReadConsentTitle: "클립보드 읽기 요청",
unsupportedTarget: "{target}에서는 사용할 수 없음",
installedVersion: "설치된 버전 v{version}",
installedVersionUpdatable: "설치된 버전 v{installed} · v{latest}(으)로 업데이트 가능",
diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts
index 00c0f2bda2..e5ab3a087f 100644
--- a/apps/desktop/src/i18n/locales/pt-BR.ts
+++ b/apps/desktop/src/i18n/locales/pt-BR.ts
@@ -185,6 +185,8 @@ export default withEnglishFallback({
verified: "Verificado",
noDescription: "Nenhuma descrição fornecida.",
permissionsCount: "{count} permissões",
+ clipboardReadConsent: '"{name}" solicita ler a área de transferência do sistema; a área de trabalho dela receberá o conteúdo atual (possivelmente senhas ou tokens que você copiou). Permitir leitura nesta sessão?',
+ clipboardReadConsentTitle: "Solicitação de leitura da área de transferência",
unsupportedTarget: "Indisponível para {target}",
installedVersion: "Instalado v{version}",
installedVersionUpdatable: "Instalado v{installed} · Atualizar para v{latest}",
diff --git a/apps/desktop/src/i18n/locales/ru.ts b/apps/desktop/src/i18n/locales/ru.ts
index 0f42c7843d..ebe2200b8d 100644
--- a/apps/desktop/src/i18n/locales/ru.ts
+++ b/apps/desktop/src/i18n/locales/ru.ts
@@ -278,6 +278,8 @@ export default withEnglishFallback({
workbenchUnavailableFallback: "Рабочая среда плагина недоступна",
pluginIncompatible: "Плагин несовместим",
loadingTitle: "Загрузка {title}",
+ clipboardReadConsent: "«{name}» запрашивает чтение системного буфера обмена. Его рабочая область получит текущее содержимое буфера обмена (возможно, содержащее скопированные вами пароли или токены). Разрешить чтение в этом запуске?",
+ clipboardReadConsentTitle: "Запрос на чтение буфера обмена",
},
auth: {
rateLimited: "Повторите попытку через {seconds} с",
diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts
index 600329ed2f..766de0f7dc 100644
--- a/apps/desktop/src/i18n/locales/zh-CN.ts
+++ b/apps/desktop/src/i18n/locales/zh-CN.ts
@@ -107,6 +107,8 @@ export default withEnglishFallback({
verified: "已认证",
noDescription: "开发者未提供说明。",
permissionsCount: "{count} 项权限",
+ clipboardReadConsent: "「{name}」请求读取系统剪贴板,其工作台将收到当前剪贴板内容(可能包含你复制的密码或令牌)。本次运行是否允许读取?",
+ clipboardReadConsentTitle: "剪贴板读取请求",
unsupportedTarget: "暂不支持 {target}",
installedVersion: "已安装 v{version}",
installedVersionUpdatable: "已安装 v{installed} · 可更新至 v{latest}",
diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts
index 7de3527b8b..7bd6f442f3 100644
--- a/apps/desktop/src/i18n/locales/zh-TW.ts
+++ b/apps/desktop/src/i18n/locales/zh-TW.ts
@@ -185,6 +185,8 @@ export default withEnglishFallback({
verified: "已驗證",
noDescription: "開發者未提供說明。",
permissionsCount: "{count} 項權限",
+ clipboardReadConsent: "「{name}」請求讀取系統剪貼簿,其工作台將收到目前剪貼簿內容(可能包含你複製的密碼或權杖)。本次執行是否允許讀取?",
+ clipboardReadConsentTitle: "剪貼簿讀取請求",
unsupportedTarget: "暫不支援 {target}",
installedVersion: "已安裝 v{version}",
installedVersionUpdatable: "已安裝 v{installed} · 可更新至 v{latest}",
diff --git a/apps/desktop/src/lib/plugins/pluginHostBridge.spec.ts b/apps/desktop/src/lib/plugins/pluginHostBridge.spec.ts
index ac11dd46c4..f3ca7940f9 100644
--- a/apps/desktop/src/lib/plugins/pluginHostBridge.spec.ts
+++ b/apps/desktop/src/lib/plugins/pluginHostBridge.spec.ts
@@ -1,6 +1,6 @@
import { describe, expect, it, vi } from "vitest";
import { reactive, readonly } from "vue";
-import { PluginHostBridge, pluginSandboxDocument, pluginSdkSource } from "./pluginHostBridge";
+import { PLUGIN_CLIPBOARD_AUDIT_CAPACITY, PluginHostBridge, clipboardReadGateAllows, createClipboardReadGate, pluginSandboxDocument, pluginSdkSource, recordClipboardRead } from "./pluginHostBridge";
import type { InstalledPlugin, PluginResultViewContribution, PluginWorkbenchContribution } from "@/types/database";
function plugin(permissions: string[] = [], contributions: InstalledPlugin["manifest"]["contributions"] = []): InstalledPlugin {
@@ -1040,6 +1040,197 @@ describe("PluginHostBridge", () => {
expect(messages[0]).toMatchObject({ id: "nocopy", error: "Host clipboard is unavailable" });
});
+ it("serves host.clipboardRead only with the declared host.clipboard:read permission", async () => {
+ const messages: unknown[] = [];
+ const target = { postMessage: (message: unknown) => messages.push(message) } as unknown as Window;
+ const clipboardRead = vi.fn().mockResolvedValue("pasted text");
+ const send = (bridge: PluginHostBridge, id: string, method: string) => bridge.handleWindowMessage({ source: target, data: { source: "dbx-plugin", version: 1, type: "request", id, method } } as MessageEvent);
+
+ // Without the permission the read is refused before the host clipboard is touched.
+ const denied = new PluginHostBridge(plugin(), workbench, {}, () => target, {
+ invoke: vi.fn(),
+ notify: vi.fn(),
+ sendBinary: vi.fn(),
+ readAsset: vi.fn(),
+ clipboardRead,
+ });
+ denied.handleWindowMessage({
+ source: target,
+ data: { source: "dbx-plugin", version: 1, type: "request", id: "read-denied", method: "host.clipboardRead" },
+ } as MessageEvent);
+ await vi.waitFor(() => expect(messages).toHaveLength(1));
+ expect(clipboardRead).not.toHaveBeenCalled();
+ expect(messages[0]).toMatchObject({ id: "read-denied", error: "Plugin has not declared permission 'host.clipboard:read'" });
+
+ // With the permission (and session consent granted) the call is scoped to
+ // the owning plugin and unwraps to { text }.
+ const allowed = new PluginHostBridge(plugin(["host.clipboard:read"]), workbench, {}, () => target, {
+ invoke: vi.fn(),
+ notify: vi.fn(),
+ sendBinary: vi.fn(),
+ readAsset: vi.fn(),
+ clipboardRead,
+ confirmClipboardRead: vi.fn().mockResolvedValue(true),
+ });
+ allowed.sendInit();
+ expect((messages[1] as { capabilities?: { clipboardRead?: boolean } }).capabilities?.clipboardRead).toBe(true);
+ send(allowed, "read-ok", "host.clipboardRead");
+ await vi.waitFor(() => expect(messages.some((message) => (message as { id?: string }).id === "read-ok")).toBe(true));
+ expect(clipboardRead).toHaveBeenCalledWith("sample");
+ const byId = new Map(messages.map((message) => [(message as { id?: string }).id, message]));
+ expect(byId.get("read-ok")).toMatchObject({ id: "read-ok", result: { text: "pasted text" } });
+ });
+
+ it("rejects host.clipboardRead when the host cannot read the clipboard and caps oversized reads", async () => {
+ const messages: unknown[] = [];
+ const target = { postMessage: (message: unknown) => messages.push(message) } as unknown as Window;
+ const send = (bridge: PluginHostBridge, id: string, method: string) => bridge.handleWindowMessage({ source: target, data: { source: "dbx-plugin", version: 1, type: "request", id, method } } as MessageEvent);
+
+ // Legacy or web host: no clipboardRead in the API surface.
+ const noRead = new PluginHostBridge(plugin(["host.clipboard:read"]), workbench, {}, () => target, {
+ invoke: vi.fn(),
+ notify: vi.fn(),
+ sendBinary: vi.fn(),
+ readAsset: vi.fn(),
+ confirmClipboardRead: vi.fn().mockResolvedValue(true),
+ });
+ send(noRead, "read-missing", "host.clipboardRead");
+ await vi.waitFor(() => expect(messages).toHaveLength(1));
+ expect(messages[0]).toMatchObject({ id: "read-missing", error: "Host clipboard read is unavailable" });
+
+ // An oversized clipboard read is clamped to the bridge payload bound.
+ const clipboardRead = vi.fn().mockResolvedValue("x".repeat(2 * 1024 * 1024 + 1));
+ const clamping = new PluginHostBridge(plugin(["host.clipboard:read"]), workbench, {}, () => target, {
+ invoke: vi.fn(),
+ notify: vi.fn(),
+ sendBinary: vi.fn(),
+ readAsset: vi.fn(),
+ clipboardRead,
+ confirmClipboardRead: vi.fn().mockResolvedValue(true),
+ });
+ send(clamping, "read-huge", "host.clipboardRead");
+ await vi.waitFor(() => expect(messages.some((message) => (message as { id?: string }).id === "read-huge")).toBe(true));
+ expect(clipboardRead).toHaveBeenCalled();
+ const byId = new Map(messages.map((message) => [(message as { id?: string }).id, message]));
+ const result = (byId.get("read-huge") as { result?: { text?: string } }).result;
+ expect(result?.text).toHaveLength(2 * 1024 * 1024);
+ });
+
+ it("asks session consent before the first clipboard read and remembers a denial for the bridge lifetime", async () => {
+ const messages: unknown[] = [];
+ const target = { postMessage: (message: unknown) => messages.push(message) } as unknown as Window;
+ const send = (bridge: PluginHostBridge, id: string, method: string) => bridge.handleWindowMessage({ source: target, data: { source: "dbx-plugin", version: 1, type: "request", id, method } } as MessageEvent);
+
+ // Denial on the consent prompt: the read rejects and the host clipboard is never touched.
+ const clipboardRead = vi.fn().mockResolvedValue("secret");
+ const deny = new PluginHostBridge(plugin(["host.clipboard:read"]), workbench, {}, () => target, {
+ invoke: vi.fn(),
+ notify: vi.fn(),
+ sendBinary: vi.fn(),
+ readAsset: vi.fn(),
+ clipboardRead,
+ confirmClipboardRead: vi.fn().mockResolvedValue(false),
+ });
+ send(deny, "consent-denied", "host.clipboardRead");
+ await vi.waitFor(() => expect(messages.some((message) => (message as { id?: string }).id === "consent-denied")).toBe(true));
+ expect(clipboardRead).not.toHaveBeenCalled();
+ const denialById = new Map(messages.map((message) => [(message as { id?: string }).id, message]));
+ expect(denialById.get("consent-denied")).toMatchObject({ id: "consent-denied", error: "Clipboard read was denied for this plugin session" });
+ expect(deny.clipboardAudit).toEqual([{ at: expect.any(Number), outcome: "denied", length: 0 }]);
+
+ // A denial is remembered: a second read rejects without asking again.
+ const confirm = (deny as unknown as { api: { confirmClipboardRead: ReturnType } }).api.confirmClipboardRead;
+ send(deny, "consent-denied-2", "host.clipboardRead");
+ await vi.waitFor(() => expect(messages.some((message) => (message as { id?: string }).id === "consent-denied-2")).toBe(true));
+ expect(confirm).toHaveBeenCalledTimes(1);
+
+ // Consent: the read proceeds, and later reads skip the prompt for the session.
+ const allow = new PluginHostBridge(plugin(["host.clipboard:read"]), workbench, {}, () => target, {
+ invoke: vi.fn(),
+ notify: vi.fn(),
+ sendBinary: vi.fn(),
+ readAsset: vi.fn(),
+ clipboardRead,
+ confirmClipboardRead: vi.fn().mockResolvedValue(true),
+ });
+ send(allow, "consent-1", "host.clipboardRead");
+ await vi.waitFor(() => expect(messages.some((message) => (message as { id?: string }).id === "consent-1" && (message as { result?: { text?: string } }).result).valueOf()).toBe(true));
+ // The rate gate demands PLUGIN_CLIPBOARD_READ_MIN_INTERVAL_MS between reads.
+ await new Promise((resolve) => setTimeout(resolve, 1_050));
+ send(allow, "consent-2", "host.clipboardRead");
+ await vi.waitFor(() => expect(messages.some((message) => (message as { id?: string }).id === "consent-2" && (message as { result?: { text?: string } }).result).valueOf()).toBe(true));
+ const allowConfirm = (allow as unknown as { api: { confirmClipboardRead: ReturnType } }).api.confirmClipboardRead;
+ expect(allowConfirm).toHaveBeenCalledTimes(1);
+ // Shared mock: the deny bridge never reached the clipboard, the allow bridge read twice.
+ expect(clipboardRead).toHaveBeenCalledTimes(2);
+ expect(readTextFromSharedBridgeAudit(allow)).toEqual(["granted", "granted"]);
+ });
+
+ function readTextFromSharedBridgeAudit(bridge: PluginHostBridge) {
+ return bridge.clipboardAudit.map((entry) => entry.outcome);
+ }
+
+ it("denies clipboard reads on hosts without a consent surface and rate-limits repeated reads", async () => {
+ const messages: unknown[] = [];
+ const target = { postMessage: (message: unknown) => messages.push(message) } as unknown as Window;
+ const send = (bridge: PluginHostBridge, id: string, method: string) => bridge.handleWindowMessage({ source: target, data: { source: "dbx-plugin", version: 1, type: "request", id, method } } as MessageEvent);
+
+ // No consent surface: deny rather than silently allow (option-a hardening).
+ const noSurface = new PluginHostBridge(plugin(["host.clipboard:read"]), workbench, {}, () => target, {
+ invoke: vi.fn(),
+ notify: vi.fn(),
+ sendBinary: vi.fn(),
+ readAsset: vi.fn(),
+ clipboardRead: vi.fn().mockResolvedValue("secret"),
+ });
+ send(noSurface, "no-surface", "host.clipboardRead");
+ await vi.waitFor(() => expect(messages.some((message) => (message as { id?: string }).id === "no-surface")).toBe(true));
+ const noSurfaceById = new Map(messages.map((message) => [(message as { id?: string }).id, message]));
+ expect(noSurfaceById.get("no-surface")).toMatchObject({ error: "Clipboard read was denied for this plugin session" });
+
+ // Rate gate: two back-to-back reads — the second is refused with a retry hint
+ // and recorded as rate-limited without touching the clipboard.
+ const clipboardRead = vi.fn().mockResolvedValue("text");
+ const readBridge = new PluginHostBridge(plugin(["host.clipboard:read"]), workbench, {}, () => target, {
+ invoke: vi.fn(),
+ notify: vi.fn(),
+ sendBinary: vi.fn(),
+ readAsset: vi.fn(),
+ clipboardRead,
+ confirmClipboardRead: vi.fn().mockResolvedValue(true),
+ });
+ send(readBridge, "rate-1", "host.clipboardRead");
+ await vi.waitFor(() => expect(messages.some((message) => (message as { id?: string }).id === "rate-1" && (message as { result?: unknown }).result).valueOf()).toBe(true));
+ send(readBridge, "rate-2", "host.clipboardRead");
+ await vi.waitFor(() => expect(messages.some((message) => (message as { id?: string }).id === "rate-2")).toBe(true));
+ const rateById = new Map(messages.map((message) => [(message as { id?: string }).id, message]));
+ expect(String((rateById.get("rate-2") as { error?: string }).error)).toContain("rate limit");
+ expect(readBridge.clipboardAudit.map((entry) => entry.outcome)).toEqual(["granted", "rate-limited"]);
+ expect(clipboardRead).toHaveBeenCalledTimes(1);
+ });
+
+ it("caps the clipboard read audit trail and exposes the pure rate helpers", () => {
+ expect(PLUGIN_CLIPBOARD_AUDIT_CAPACITY).toBeLessThanOrEqual(500);
+ const gate = createClipboardReadGate();
+ for (let index = 0; index < PLUGIN_CLIPBOARD_AUDIT_CAPACITY + 20; index += 1) {
+ recordClipboardRead(gate, index * 10_000, "granted", index);
+ }
+ expect(gate.audit).toHaveLength(PLUGIN_CLIPBOARD_AUDIT_CAPACITY);
+ expect(gate.audit[0].length).toBe(20);
+ // Exactly one read per min interval passes; within the window the gate refuses.
+ expect(clipboardReadGateAllows(gate, gate.audit[PLUGIN_CLIPBOARD_AUDIT_CAPACITY - 1].at + 999)).toBe(false);
+ expect(clipboardReadGateAllows(gate, gate.audit[PLUGIN_CLIPBOARD_AUDIT_CAPACITY - 1].at + 1_000)).toBe(true);
+ // A fresh gate (never read) always allows the first read.
+ expect(clipboardReadGateAllows(createClipboardReadGate(), 0)).toBe(true);
+ });
+
+ it("exposes the sandbox clipboard namespace mapping writeText to host.copy and readText to host.clipboardRead", () => {
+ const source = pluginSdkSource();
+ expect(source).toContain("clipboard: Object.freeze({");
+ expect(source).toContain("writeText: (text) => request('host.copy', { text })");
+ expect(source).toContain("request('host.clipboardRead')");
+ });
+
it("routes host.storage through the owning plugin, caps values, and needs the declared permission", async () => {
const messages: unknown[] = [];
const target = { postMessage: (message: unknown) => messages.push(message) } as unknown as Window;
diff --git a/apps/desktop/src/lib/plugins/pluginHostBridge.ts b/apps/desktop/src/lib/plugins/pluginHostBridge.ts
index 9c0cafd858..b465cf7da3 100644
--- a/apps/desktop/src/lib/plugins/pluginHostBridge.ts
+++ b/apps/desktop/src/lib/plugins/pluginHostBridge.ts
@@ -15,6 +15,48 @@ const MAX_BRIDGE_SAVE_BYTES = 512 * 1024 * 1024;
// Mirrors MAX_PLUGIN_PLAN_NAME_CHARS in crates/dbx-core/src/query/plugin_plan.rs.
const MAX_PLUGIN_PLAN_IDENTIFIER_CHARS = 256;
+// Clipboard reads are the one permission that hands environment data (the
+// system clipboard) to plugin code with no user interaction on each call, so
+// beyond the manifest permission gate the bridge adds: a per-session consent
+// prompt before the first read, a bounded audit trail, and a read-rate cap.
+// A plugin that trips the cap waits rather than being able to silently poll.
+export const PLUGIN_CLIPBOARD_READ_MIN_INTERVAL_MS = 1_000;
+export const PLUGIN_CLIPBOARD_AUDIT_CAPACITY = 200;
+
+export interface PluginClipboardAuditEntry {
+ at: number;
+ /** Request outcome: granted (content returned), denied (user or no consent surface). */
+ outcome: "granted" | "denied" | "rate-limited";
+ /** Content length in UTF-16 code units; the content itself is never stored. */
+ length: number;
+}
+
+export interface PluginClipboardReadGateState {
+ /** Consent for the current bridge lifetime; null = never asked. */
+ consented: boolean | null;
+ lastReadAt: number;
+ audit: PluginClipboardAuditEntry[];
+}
+
+export function createClipboardReadGate(): PluginClipboardReadGateState {
+ return { consented: null, lastReadAt: 0, audit: [] };
+}
+
+/**
+ * Rate gate: at most one read per PLUGIN_CLIPBOARD_READ_MIN_INTERVAL_MS.
+ * Returns true when the read may proceed; a denied (rate-limited) read is
+ * recorded by the caller.
+ */
+export function clipboardReadGateAllows(state: PluginClipboardReadGateState, now: number): boolean {
+ return state.lastReadAt <= 0 || now - state.lastReadAt >= PLUGIN_CLIPBOARD_READ_MIN_INTERVAL_MS;
+}
+
+export function recordClipboardRead(state: PluginClipboardReadGateState, now: number, outcome: PluginClipboardAuditEntry["outcome"], length: number): void {
+ if (outcome !== "rate-limited") state.lastReadAt = now;
+ state.audit.push({ at: now, outcome, length });
+ if (state.audit.length > PLUGIN_CLIPBOARD_AUDIT_CAPACITY) state.audit.splice(0, state.audit.length - PLUGIN_CLIPBOARD_AUDIT_CAPACITY);
+}
+
/** Structured editor appearance: SQL editor settings that have no CSS-token
* carrier (font size is a number, the syntax theme an id). Font families are
* additionally mirrored as `--font-sans` / `--font-mono` root tokens. */
@@ -125,6 +167,20 @@ export interface PluginHostBridgeApi {
cancelDownload?(pluginId: string, downloadId: string): Promise;
/** Write text to the system clipboard on behalf of the sandboxed plugin iframe. */
copyText?(pluginId: string, text: string): Promise;
+ /**
+ * Read the system clipboard on behalf of the sandboxed plugin iframe.
+ * Requires the plugin to declare `host.clipboard:read`: unlike writes, a
+ * read hands arbitrary user data (passwords, tokens) to plugin code with no
+ * further user interaction, so it is permission-gated.
+ */
+ clipboardRead?(pluginId: string): Promise;
+ /**
+ * Session consent prompt for the first clipboard read of a bridge lifetime.
+ * Resolves true to allow (and remember for the workbench session), false to
+ * deny (the read request rejects). Optional on hosts without a dialog
+ * surface; a host that cannot ask must not silently allow.
+ */
+ confirmClipboardRead?(pluginId: string, pluginName: string): Promise | boolean;
/** Native open dialog; resolves opened read handles (null selection → empty list). */
pickFiles?(pluginId: string, options: PluginPickFilesOptions): Promise;
/** Stream a chunk from an opened read handle. */
@@ -159,6 +215,13 @@ export class PluginHostBridge {
private context: PluginWorkbenchContext;
private locale: string;
private theme?: PluginBridgeTheme;
+ /** Consent + audit + rate state for clipboard reads; lives for the bridge lifetime. */
+ private clipboardReadGate = createClipboardReadGate();
+
+ /** Bounded audit trail of this session's clipboard read attempts (oldest first). */
+ get clipboardAudit(): readonly PluginClipboardAuditEntry[] {
+ return this.clipboardReadGate.audit;
+ }
/** Invoked once before each iframe load generation sends its init message. */
onReinit?: () => Promise | void;
@@ -303,6 +366,10 @@ export class PluginHostBridge {
[PLUGIN_SCHEMA_METADATA_CAPABILITY]: !!this.api.getTableMetadata,
storage: !!this.api.storageGet && !!this.api.storageSet && !!this.api.storageDelete,
ai: !!this.api.openAiConversation,
+ // Additive with the same "absence means unsupported" contract: an older
+ // host omits these, and a web host has neither.
+ clipboardWrite: !!this.api.copyText,
+ clipboardRead: !!this.api.clipboardRead,
},
context: snapshotPluginWorkbenchContext(this.context),
});
@@ -485,6 +552,35 @@ export class PluginHostBridge {
await this.api.copyText(this.plugin.manifest.id, input.text);
return { success: true };
}
+ if (method === "host.clipboardRead") {
+ // Reads are the sensitive half of the clipboard surface: the payload is
+ // user data heading into plugin code, so the manifest must declare
+ // `host.clipboard:read` (writes stay on ungated host.copy).
+ this.requirePermission("host.clipboard:read");
+ if (!this.api.clipboardRead) throw new Error("Host clipboard read is unavailable");
+ const now = Date.now();
+ if (!clipboardReadGateAllows(this.clipboardReadGate, now)) {
+ recordClipboardRead(this.clipboardReadGate, now, "rate-limited", 0);
+ throw new Error("Clipboard read rate limit exceeded; retry in a moment");
+ }
+ // Session consent: the first read asks the user through the host's
+ // dialog surface; a denial is remembered for this workbench session (an
+ // iframe reload rebuilds the bridge and asks again). A host without a
+ // consent surface denies rather than silently allowing.
+ if (this.clipboardReadGate.consented === null) {
+ const answer = this.api.confirmClipboardRead ? await this.api.confirmClipboardRead(this.plugin.manifest.id, this.plugin.manifest.name) : false;
+ this.clipboardReadGate.consented = answer === true;
+ if (!this.clipboardReadGate.consented) {
+ recordClipboardRead(this.clipboardReadGate, now, "denied", 0);
+ throw new Error("Clipboard read was denied for this plugin session");
+ }
+ }
+ const text = await this.api.clipboardRead(this.plugin.manifest.id);
+ if (typeof text !== "string") throw new Error("Host clipboard read returned a non-string value");
+ const clamped = text.length > MAX_BRIDGE_PAYLOAD_BYTES ? text.slice(0, MAX_BRIDGE_PAYLOAD_BYTES) : text;
+ recordClipboardRead(this.clipboardReadGate, now, "granted", clamped.length);
+ return { text: clamped };
+ }
if (method === "host.pickFiles") {
// Same trust level as host.saveFile: the bytes only flow after the user
// picked the files in the native dialog, so no manifest permission gate.
@@ -888,6 +984,17 @@ export function pluginSdkSource(initialTheme?: PluginBridgeTheme): string {
return request('host.saveFile', options, { transfer: bytes });
},
copy: (text) => request('host.copy', { text }),
+ // System clipboard surface: writeText rides the ungated host.copy path,
+ // readText is served by host.clipboardRead and requires the plugin to
+ // declare the host.clipboard:read permission (the bridge rejects
+ // otherwise, and capabilities.clipboardRead advertises support).
+ clipboard: Object.freeze({
+ writeText: (text) => request('host.copy', { text }),
+ readText: async () => {
+ const result = await request('host.clipboardRead');
+ return (result && typeof result === 'object' && typeof result.text === 'string') ? result.text : '';
+ },
+ }),
// Persistent per-plugin key-value state; gate on capabilities.storage
// (older hosts omit it) and declare the host.storage permission.
storage: Object.freeze({
diff --git a/apps/desktop/src/lib/plugins/pluginPermissions.ts b/apps/desktop/src/lib/plugins/pluginPermissions.ts
new file mode 100644
index 0000000000..7ca980d4b3
--- /dev/null
+++ b/apps/desktop/src/lib/plugins/pluginPermissions.ts
@@ -0,0 +1,13 @@
+/**
+ * Permission display classification for the plugin center. The badge shows the
+ * count; the detail surface lists the real permission strings so a user can see
+ * exactly what a plugin asks for before installing. Permissions in
+ * SENSITIVE_PLUGIN_PERMISSIONS hand user data or environment state to plugin
+ * code without an interaction per call (or open a direct environment channel),
+ * so they are highlighted instead of blended into the outline badge row.
+ */
+export const SENSITIVE_PLUGIN_PERMISSIONS: readonly string[] = ["host.clipboard:read"];
+
+export function isSensitivePluginPermission(permission: string): boolean {
+ return SENSITIVE_PLUGIN_PERMISSIONS.includes(permission);
+}
diff --git a/crates/dbx-plugin-runtime/src/plugins/manifest.rs b/crates/dbx-plugin-runtime/src/plugins/manifest.rs
index 01e88bea9d..3d597ca5b3 100644
--- a/crates/dbx-plugin-runtime/src/plugins/manifest.rs
+++ b/crates/dbx-plugin-runtime/src/plugins/manifest.rs
@@ -10,10 +10,12 @@ pub const SUPPORTED_PLUGIN_MANIFEST_VERSION: u32 = 1;
/// 1.1 adds the plugin-initiated `host/requestUserInput` method (see
/// `plugins/runtime.rs`). 1.2 adds the plugin-initiated plan Host API
/// (`host.getPlanCapabilities` / `host.explainPlan`). 1.3 adds read-only table
-/// schema metadata (`host.getTableMetadata`). All are additive: older plugins
-/// keep working, and a plugin that wants either capability must check the
-/// advertised version (or the matching `capabilities` / `host.features`
-/// entry) before calling it.
+/// schema metadata (`host.getTableMetadata` behind `host.schema:read`) and the
+/// plugin-initiated clipboard Host API (`host.clipboardRead` behind the
+/// `host.clipboard:read` permission; clipboard writes reuse the existing
+/// ungated `host.copy`). All are additive: older plugins keep working, and a
+/// plugin that wants a capability must check the advertised version (or the
+/// matching `capabilities` / `host.features` entry) before calling it.
pub const SUPPORTED_PLUGIN_HOST_API_VERSION: &str = "1.3.0";
/// Capabilities the host advertises to a plugin backend at `plugin/initialize`.
pub const SUPPORTED_PLUGIN_HOST_FEATURES: &[&str] = &["host.requestUserInput"];
@@ -31,6 +33,7 @@ pub const SUPPORTED_PLUGIN_PERMISSIONS: &[&str] = &[
"host.schema:read",
"host.storage",
"host.ai",
+ "host.clipboard:read",
];
/// Cap the number of `host.network:` entries so a manifest cannot bloat
@@ -2211,8 +2214,9 @@ mod tests {
}
/// The reason for the 1.3.0 bump: `engines.host_api` is how a plugin states
- /// "I need the schema metadata API", so the advertised version has to
- /// satisfy `^1.3` while a floor this host cannot meet stays rejected.
+ /// "I need the schema metadata API" or "I need clipboard reads", so the
+ /// advertised version has to satisfy `^1.3` while a floor this host cannot
+ /// meet stays rejected.
#[test]
fn host_api_advertises_the_floor_a_schema_metadata_plugin_declares() {
let advertised = semver::Version::parse(SUPPORTED_PLUGIN_HOST_API_VERSION)
@@ -2221,6 +2225,10 @@ mod tests {
semver::VersionReq::parse("^1.3").unwrap().matches(&advertised),
"the host must satisfy the schema metadata API floor it asks plugins to declare"
);
+ assert!(
+ semver::VersionReq::parse("^1.3").unwrap().matches(&advertised),
+ "the host must satisfy the clipboard-read floor it asks plugins to declare"
+ );
for requirement in ["^1.0", "^1.1", "^1.2", "^1.3", ">=1.1.0, <2.0.0"] {
assert!(host_api_requirement_errors(requirement).is_empty(), "{requirement} must be satisfiable");
diff --git a/docs/content/docs/plugin-development.cn.mdx b/docs/content/docs/plugin-development.cn.mdx
index f58f71e0ef..27485e57cc 100644
--- a/docs/content/docs/plugin-development.cn.mdx
+++ b/docs/content/docs/plugin-development.cn.mdx
@@ -105,6 +105,7 @@ Manifest 中的路径相对于包根目录,不能以 `/` 开头、不能包含
"host.plans:read",
"host.schema:read",
"host.storage",
+ "host.clipboard:read",
"host.network:https://s3.example.com:443"
],
"entrypoints": {
@@ -124,6 +125,7 @@ Manifest 中的路径相对于包根目录,不能以 `/` 开头、不能包含
- `host.schema:read` 允许通过 `host.getTableMetadata` 读取已打开连接的窄化 table schema metadata;它不会授予任意 SQL、写入或隐式重连(见 [Table Schema Metadata](#table-schema-metadata))。
- `host.storage` 为工作台 UI 提供本插件独享的持久化键值存储(见 [持久化 UI 状态](#持久化-ui-状态));数据只落在插件自己的插件数据目录里。
- `host.ai` 允许插件在 DBX 内置 AI 面板中新建带数据快照的对话,沿用宿主的模型选择、历史、追问、停止和导出功能。旧版 DBX 会在安装时因未知权限而失败,需要兼容旧宿主的插件应把 `host.ai` 作为可选能力。
+- `host.clipboard:read` 允许工作台 UI 读取系统剪贴板(见 [系统剪贴板](#系统剪贴板))。写剪贴板不需要权限;读取会把用户数据(密码、令牌)交给插件代码,因此需要门控。
- `host.network:https://host[:port]` 声明浏览器可访问的 HTTPS origin,DBX 将它加入 CSP 的 `connect-src`;最多 8 个,不允许路径、通配符或 Token。仍受目标服务 CORS 规则约束。该权限不是原生 Sidecar 的网络防火墙。
- `backend.transport` 默认是 `stdio-jsonl`;需要二进制帧时使用 `stdio-framed`,并同时声明 `host.binary`。
- `backend.executable`、`ui.root` 和 `ui.entry` 必须指向包内文件;带原生后端的包必须为每个目标平台提供对应可执行文件。
@@ -316,11 +318,12 @@ const assetUrl = await window.dbxPlugin.readAssetUrl("assets/empty-state.svg");
| `getPlanCapabilities(connectionId)` | 读取宿主与该连接能提供什么计划能力;需要 `host.plans:read`。 |
| `explainPlan({ connectionId, database?, schema?, sql, mode, timeoutMs? })` | 返回 `sql` 的估算执行计划;需要 `host.plans:read`。 |
| `getTableMetadata({ connectionId, database?, schema?, table })` | 读取已打开连接的窄化 table schema metadata;需要 `host.schema:read`。 |
-| `capabilities` | 来自 init 消息的 `{ downloadFile, planApi, schemaMetadataApi, storage, ai }`。字段缺失或为 `false` 表示当前宿主不支持该组 Host API,应据此关闭功能而不是用真实请求试探。 |
+| `capabilities` | 来自 init 消息的 `{ downloadFile, planApi, schemaMetadataApi, storage, ai, clipboardWrite, clipboardRead }`。字段缺失或为 `false` 表示当前宿主不支持该组 Host API,应据此关闭功能而不是用真实请求试探。 |
| `ai.openConversation({ title, prompt, context, send? })` | 在内置 AI 面板新建插件对话;`send` 默认 `false`,为 `true` 时立即分析;需要 `host.ai`,并以 `capabilities.ai` 广播——旧宿主不带该字段,请按能力位关闭而不是用真实请求试探。 |
| `onInit(fn)` / `onEvent(fn)` | 监听初始化、环境变化或后端事件;转发后端事件需要 `host.events`。 |
| `storage` | 本插件独享的持久化键值状态;需要 `host.storage`;见下文。 |
| `fileTransfer` | 本地文件流式传输与系统拖放(仅桌面端);见下文。 |
+| `clipboard` | 沙箱内的系统剪贴板访问;见下文。 |
调用失败会以 Promise rejection 返回,处理时应展示可理解的错误并允许重试。通过 `request` 调用的宿主方法、参数和返回值以当前 Host API 版本为准,不要调用未在协议中声明的 DBX 内部函数。
@@ -423,6 +426,31 @@ if (window.dbxPlugin.capabilities.schemaMetadataApi) {
该 API 属于 Host API 1.3。无法离开它工作的插件应声明:`"engines": { "host_api": "^1.3" }`;同时仍应读取 `capabilities.schemaMetadataApi`,因为旧宿主不会提供该字段。没有 `host.schema:read` 时,宿主会在请求到达 backend 前拒绝调用。
+### 系统剪贴板
+
+插件沙箱是 opaque origin 且没有剪贴板权限,`navigator.clipboard` 在里面不可用。宿主代为桥接系统剪贴板:
+
+- `window.dbxPlugin.copy(text)` / `window.dbxPlugin.clipboard.writeText(text)` 写系统剪贴板。两者走同一条无权限门控的桥接方法:写操作的输入就是用户数据本身,是低风险的一半。
+- `window.dbxPlugin.clipboard.readText()` 读系统剪贴板。它需要 `host.clipboard:read` 权限——读取会把用户数据(密码、令牌)交给插件代码,且没有后续用户交互兜底——属于 Host API 1.3:离开它无法工作的插件应声明 `"engines": { "host_api": "^1.3" }`。
+
+读剪贴板请看 init 能力而不是试探:`capabilities.clipboardRead`(写对应 `capabilities.clipboardWrite`)在旧宿主上缺失即为 falsy。读被拒绝(缺权限、Web 宿主没有原生剪贴板访问)时 Promise 会 reject——请优雅降级,例如回落到键盘粘贴路径,而不是让交互直接失败。
+
+```js
+await window.dbxPlugin.ready;
+const canRead = !!window.dbxPlugin.capabilities.clipboardRead;
+const canWrite = !!window.dbxPlugin.capabilities.clipboardWrite;
+
+async function pasteFromClipboard() {
+ if (!canRead) return null;
+ try {
+ return await window.dbxPlugin.clipboard.readText();
+ } catch {
+ // 缺权限或读取失败:保留键盘粘贴路径。
+ return null;
+ }
+}
+```
+
### 文件传输与系统拖放
桌面端宿主通过 `window.dbxPlugin.fileTransfer` 把本地文件流式接入插件沙箱。句柄只在用户明确同意后打开:原生打开/保存对话框(`pick` / `beginSave`),或从系统拖放到本插件工作台区域内的文件(`onDrop`)。传输按分块进行,多 GB 文件也不会整体载入内存。
diff --git a/docs/content/docs/plugin-development.mdx b/docs/content/docs/plugin-development.mdx
index bd56ab9301..7e1a790b80 100644
--- a/docs/content/docs/plugin-development.mdx
+++ b/docs/content/docs/plugin-development.mdx
@@ -105,6 +105,7 @@ Manifest paths are relative to the package root. They cannot start with `/`, con
"host.plans:read",
"host.schema:read",
"host.storage",
+ "host.clipboard:read",
"host.network:https://s3.example.com:443"
],
"entrypoints": {
@@ -124,6 +125,7 @@ Manifest paths are relative to the package root. They cannot start with `/`, con
- `host.schema:read` allows `host.getTableMetadata` to read narrow table schema metadata from an already-open connection; it grants no arbitrary SQL, writes, or implicit reconnect (see [Table Schema Metadata](#table-schema-metadata)).
- `host.storage` gives the workbench UI a persistent key-value store scoped to this plugin (see [Persistent UI State](#persistent-ui-state)). Values never leave the plugin's own data directory.
- `host.ai` lets the plugin open a conversation with a data snapshot in the built-in DBX AI panel, using its model selector, history, follow-up messages, cancellation and export. Older DBX builds reject the unknown permission at install time, so plugins that must install everywhere should treat `host.ai` as optional.
+- `host.clipboard:read` allows reading the system clipboard from the workbench UI (see [System Clipboard](#system-clipboard)). Clipboard writes need no permission; reads hand user data (passwords, tokens) to plugin code, so they are gated.
- `host.network:https://host[:port]` declares a browser-accessible HTTPS origin added to CSP `connect-src`; at most eight origins are allowed, with no paths, wildcards, or tokens. The service's CORS rules still apply. This permission is not a native Sidecar network firewall.
- `backend.transport` defaults to `stdio-jsonl`. Use `stdio-framed` for binary frames and declare `host.binary` as well.
- `backend.executable`, `ui.root`, and `ui.entry` must point to package files. Native packages need a matching executable for every target.
@@ -316,11 +318,12 @@ const assetUrl = await window.dbxPlugin.readAssetUrl("assets/empty-state.svg");
| `getPlanCapabilities(connectionId)` | Reads what the host and this connection can plan; requires `host.plans:read`. |
| `explainPlan({ connectionId, database?, schema?, sql, mode, timeoutMs? })` | Returns the estimated plan for `sql`; requires `host.plans:read`. |
| `getTableMetadata({ connectionId, database?, schema?, table })` | Reads narrow table schema metadata from an already-open connection; requires `host.schema:read`. |
-| `capabilities` | `{ downloadFile, planApi, schemaMetadataApi, storage, ai }` from the init message. A missing or `false` entry means that Host API group is unavailable here, so gate the matching call on it instead of probing with a request. |
+| `capabilities` | `{ downloadFile, planApi, schemaMetadataApi, storage, ai, clipboardWrite, clipboardRead }` from the init message. A missing or `false` entry means that Host API group is unavailable here, so gate the matching call on it instead of probing with a request. |
| `ai.openConversation({ title, prompt, context, send? })` | Opens a new plugin conversation in the built-in AI panel; `send` defaults to `false`, or starts analysis when `true`; requires `host.ai` and is advertised as `capabilities.ai` — gate on it instead of probing, since older hosts omit it. |
| `onInit(fn)` / `onEvent(fn)` | Observes initialization, environment changes, or backend events; forwarding backend events requires `host.events`. |
| `storage` | Persistent per-plugin key-value state; requires `host.storage`; see below. |
| `fileTransfer` | Streams local files and OS drops (desktop only); see below. |
+| `clipboard` | System clipboard access from the sandbox; see below. |
Failed calls reject their Promise; show a useful error and offer retry. Host methods, parameters, and results are versioned API surface—do not call undocumented DBX internals.
@@ -423,6 +426,31 @@ if (window.dbxPlugin.capabilities.schemaMetadataApi) {
This API is part of Host API 1.3. A plugin that cannot work without it should declare `"engines": { "host_api": "^1.3" }` and still read `capabilities.schemaMetadataApi`, because older hosts omit that field. Without `host.schema:read`, the host rejects the request before it reaches the backend.
+### System Clipboard
+
+The plugin sandbox has an opaque origin and no clipboard permission, so `navigator.clipboard` is unavailable there. The host bridges the system clipboard instead:
+
+- `window.dbxPlugin.copy(text)` / `window.dbxPlugin.clipboard.writeText(text)` write to the system clipboard. Both ride the same ungated bridge method: a write has the user's data as its input and is the low-risk half of the surface.
+- `window.dbxPlugin.clipboard.readText()` reads the system clipboard. It requires the `host.clipboard:read` permission — a read hands user data (passwords, tokens) to plugin code with no further user interaction — and is Host API 1.3: declare `"engines": { "host_api": "^1.3" }` if your workbench cannot function without it.
+
+Gate reads on the init capability rather than probing: `capabilities.clipboardRead` (and `capabilities.clipboardWrite` for writes) are absent on older hosts, which makes them falsy. A rejected read (missing permission, web host without native clipboard access) rejects its Promise — degrade gracefully, e.g. fall back to a keyboard-paste path instead of failing the interaction.
+
+```js
+await window.dbxPlugin.ready;
+const canRead = !!window.dbxPlugin.capabilities.clipboardRead;
+const canWrite = !!window.dbxPlugin.capabilities.clipboardWrite;
+
+async function pasteFromClipboard() {
+ if (!canRead) return null;
+ try {
+ return await window.dbxPlugin.clipboard.readText();
+ } catch {
+ // Permission missing or the clipboard read failed: keep the keyboard path.
+ return null;
+ }
+}
+```
+
### File Transfer and OS Drops
Desktop hosts stream local files into plugin sandboxes through `window.dbxPlugin.fileTransfer`. Handles are opened only after explicit user consent: a native open/save dialog (`pick` / `beginSave`) or a file dropped from the OS onto this plugin's workbench area (`onDrop`). Transfers are chunked, so multi-gigabyte files never load into memory.
diff --git a/plugins/manifest.schema.json b/plugins/manifest.schema.json
index 37d2e5fab8..a7b54164b1 100644
--- a/plugins/manifest.schema.json
+++ b/plugins/manifest.schema.json
@@ -30,7 +30,7 @@
"uniqueItems": true,
"items": {
"anyOf": [
- { "enum": ["host.events", "host.binary", "host.workbench", "host.filesystem", "host.plans:read", "host.schema:read", "host.storage", "host.ai"] },
+ { "enum": ["host.events", "host.binary", "host.workbench", "host.filesystem", "host.plans:read", "host.schema:read", "host.storage", "host.ai", "host.clipboard:read"] },
{ "type": "string", "pattern": "^host\\.network:https://[A-Za-z0-9._-]+(:[0-9]+)?$" }
]
}