feat(plugins): bridge clipboard reads with consent gate
This commit is contained in:
1 parent
1335ec032c
commit
92f92f6cdb
18 files changed
+424
-12
No files matched your search
@@ -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(() => {
|
||||
</div>
|
||||
<div class="mt-3 flex flex-wrap gap-1.5">
|
||||
<Badge v-for="tag in listing.plugin.tags.slice(0, 3)" :key="tag" variant="outline" class="h-5 px-1.5 text-[10px]">{{ tag }}</Badge>
|
||||
<Badge v-if="listing.plugin.permissions.length" variant="outline" class="h-5 px-1.5 text-[10px]">{{ t("pluginPlatform.permissionsCount", { count: listing.plugin.permissions.length }) }}</Badge>
|
||||
<!-- Real permission strings, not a count badge: sensitive ones (clipboard read, …)
|
||||
highlight in the destructive variant so a user sees the risk surface before
|
||||
installing; the rest stay muted. -->
|
||||
<Tooltip :delay-duration="300">
|
||||
<TooltipTrigger as-child>
|
||||
<Badge v-if="listing.plugin.permissions.length" variant="outline" class="h-5 px-1.5 font-mono text-[10px]">{{ listing.plugin.permissions.join(" · ") }}</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" class="max-w-md break-all font-mono text-[11px]">{{ listing.plugin.permissions.join("\n") }}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Badge v-for="permission in listing.plugin.permissions" :key="permission" v-show="isSensitivePluginPermission(permission)" variant="destructive" class="h-5 px-1.5 font-mono text-[10px]" :data-sensitive-permission="permission">{{ permission }}</Badge>
|
||||
</div>
|
||||
<Tooltip :delay-duration="700">
|
||||
<TooltipTrigger as-child>
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -185,6 +185,8 @@ export default withEnglishFallback({
|
||||
verified: "認証済み",
|
||||
noDescription: "説明はありません。",
|
||||
permissionsCount: "権限 {count} 件",
|
||||
clipboardReadConsent: "「{name}」がシステムクリップボードの読み取りを要求しています。そのワークベンチは現在のクリップボードの内容(コピーしたパスワードやトークンを含む可能性があります)を受け取ります。このセッションで読み取りを許可しますか?",
|
||||
clipboardReadConsentTitle: "クリップボード読み取りの要求",
|
||||
unsupportedTarget: "{target} では利用できません",
|
||||
installedVersion: "インストール済み v{version}",
|
||||
installedVersionUpdatable: "インストール済み v{installed} · v{latest} に更新可能",
|
||||
|
||||
@@ -184,6 +184,8 @@ export default withEnglishFallback({
|
||||
verified: "검증됨",
|
||||
noDescription: "설명이 제공되지 않았습니다.",
|
||||
permissionsCount: "권한 {count}개",
|
||||
clipboardReadConsent: '"{name}" 플러그인이 시스템 클립보드 읽기를 요청합니다. 해당 워크벤치는 현재 클립보드 내용(복사한 비밀번호나 토큰이 포함될 수 있음)을 받게 됩니다. 이 세션에서 읽기를 허용하시겠습니까?',
|
||||
clipboardReadConsentTitle: "클립보드 읽기 요청",
|
||||
unsupportedTarget: "{target}에서는 사용할 수 없음",
|
||||
installedVersion: "설치된 버전 v{version}",
|
||||
installedVersionUpdatable: "설치된 버전 v{installed} · v{latest}(으)로 업데이트 가능",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -278,6 +278,8 @@ export default withEnglishFallback({
|
||||
workbenchUnavailableFallback: "Рабочая среда плагина недоступна",
|
||||
pluginIncompatible: "Плагин несовместим",
|
||||
loadingTitle: "Загрузка {title}",
|
||||
clipboardReadConsent: "«{name}» запрашивает чтение системного буфера обмена. Его рабочая область получит текущее содержимое буфера обмена (возможно, содержащее скопированные вами пароли или токены). Разрешить чтение в этом запуске?",
|
||||
clipboardReadConsentTitle: "Запрос на чтение буфера обмена",
|
||||
},
|
||||
auth: {
|
||||
rateLimited: "Повторите попытку через {seconds} с",
|
||||
|
||||
@@ -107,6 +107,8 @@ export default withEnglishFallback({
|
||||
verified: "已认证",
|
||||
noDescription: "开发者未提供说明。",
|
||||
permissionsCount: "{count} 项权限",
|
||||
clipboardReadConsent: "「{name}」请求读取系统剪贴板,其工作台将收到当前剪贴板内容(可能包含你复制的密码或令牌)。本次运行是否允许读取?",
|
||||
clipboardReadConsentTitle: "剪贴板读取请求",
|
||||
unsupportedTarget: "暂不支持 {target}",
|
||||
installedVersion: "已安装 v{version}",
|
||||
installedVersionUpdatable: "已安装 v{installed} · 可更新至 v{latest}",
|
||||
|
||||
@@ -185,6 +185,8 @@ export default withEnglishFallback({
|
||||
verified: "已驗證",
|
||||
noDescription: "開發者未提供說明。",
|
||||
permissionsCount: "{count} 項權限",
|
||||
clipboardReadConsent: "「{name}」請求讀取系統剪貼簿,其工作台將收到目前剪貼簿內容(可能包含你複製的密碼或權杖)。本次執行是否允許讀取?",
|
||||
clipboardReadConsentTitle: "剪貼簿讀取請求",
|
||||
unsupportedTarget: "暫不支援 {target}",
|
||||
installedVersion: "已安裝 v{version}",
|
||||
installedVersionUpdatable: "已安裝 v{installed} · 可更新至 v{latest}",
|
||||
|
||||
+192
-1
@@ -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<typeof vi.fn> } }).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<typeof vi.fn> } }).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;
|
||||
|
||||
@@ -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<void>;
|
||||
/** Write text to the system clipboard on behalf of the sandboxed plugin iframe. */
|
||||
copyText?(pluginId: string, text: string): Promise<void>;
|
||||
/**
|
||||
* 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<string>;
|
||||
/**
|
||||
* 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> | boolean;
|
||||
/** Native open dialog; resolves opened read handles (null selection → empty list). */
|
||||
pickFiles?(pluginId: string, options: PluginPickFilesOptions): Promise<PluginFileHandleMeta[]>;
|
||||
/** 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> | 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({
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user