diff --git a/.gitignore b/.gitignore index ba71e77..cf49d61 100644 --- a/.gitignore +++ b/.gitignore @@ -4,7 +4,8 @@ __pycache__/ /CLAUDE.md v2/target/ debug/ -plugins/ +/plugins/ +/v2/plugins/ projects/ .playwright-mcp/ .audit/ diff --git a/v2/src/lib/plugins/plugin-host.test.ts b/v2/src/lib/plugins/plugin-host.test.ts new file mode 100644 index 0000000..0caf513 --- /dev/null +++ b/v2/src/lib/plugins/plugin-host.test.ts @@ -0,0 +1,373 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// --- Mocks --- + +const { mockInvoke } = vi.hoisted(() => ({ + mockInvoke: vi.fn(), +})); + +vi.mock('@tauri-apps/api/core', () => ({ + invoke: mockInvoke, +})); + +// Mock the plugins store to avoid Svelte 5 rune issues in test context +vi.mock('../stores/plugins.svelte', () => { + const commands: Array<{ pluginId: string; label: string; callback: () => void }> = []; + return { + addPluginCommand: vi.fn((pluginId: string, label: string, callback: () => void) => { + commands.push({ pluginId, label, callback }); + }), + removePluginCommands: vi.fn((pluginId: string) => { + const toRemove = commands.filter(c => c.pluginId === pluginId); + for (const cmd of toRemove) { + const idx = commands.indexOf(cmd); + if (idx >= 0) commands.splice(idx, 1); + } + }), + pluginEventBus: { + on: vi.fn(), + off: vi.fn(), + emit: vi.fn(), + clear: vi.fn(), + }, + getPluginCommands: () => [...commands], + }; +}); + +import { + loadPlugin, + unloadPlugin, + getLoadedPlugins, + unloadAllPlugins, +} from './plugin-host'; +import { addPluginCommand, removePluginCommands } from '../stores/plugins.svelte'; +import type { PluginMeta } from '../adapters/plugins-bridge'; +import type { GroupId, AgentId } from '../types/ids'; + +// --- Helpers --- + +function makeMeta(overrides: Partial = {}): PluginMeta { + return { + id: overrides.id ?? 'test-plugin', + name: overrides.name ?? 'Test Plugin', + version: overrides.version ?? '1.0.0', + description: overrides.description ?? 'A test plugin', + main: overrides.main ?? 'index.js', + permissions: overrides.permissions ?? [], + }; +} + +/** Set mockInvoke to return the given code when plugin_read_file is called */ +function mockPluginCode(code: string): void { + mockInvoke.mockImplementation((cmd: string) => { + if (cmd === 'plugin_read_file') return Promise.resolve(code); + return Promise.reject(new Error(`Unexpected invoke: ${cmd}`)); + }); +} + +const GROUP_ID = 'test-group' as GroupId; +const AGENT_ID = 'test-agent' as AgentId; + +beforeEach(() => { + vi.clearAllMocks(); + unloadAllPlugins(); +}); + +// --- Sandbox escape prevention tests --- + +describe('plugin-host sandbox', () => { + describe('global shadowing', () => { + // `eval` is intentionally excluded: `var eval` is a SyntaxError in strict mode. + // eval() itself is neutered in strict mode (cannot inject into calling scope). + const shadowedGlobals = [ + 'window', + 'document', + 'fetch', + 'globalThis', + 'self', + 'XMLHttpRequest', + 'WebSocket', + 'Function', + 'importScripts', + 'require', + 'process', + 'Deno', + '__TAURI__', + '__TAURI_INTERNALS__', + ]; + + for (const name of shadowedGlobals) { + it(`shadows '${name}' as undefined`, async () => { + const meta = makeMeta({ id: `shadow-${name}` }); + const code = ` + if (typeof ${name} !== 'undefined') { + throw new Error('ESCAPE: ${name} is accessible'); + } + `; + mockPluginCode(code); + await expect(loadPlugin(meta, GROUP_ID, AGENT_ID)).resolves.toBeUndefined(); + }); + } + }); + + describe('this binding', () => { + it('this is undefined in strict mode (cannot reach global scope)', async () => { + const meta = makeMeta({ id: 'this-test' }); + mockPluginCode(` + if (this !== undefined) { + throw new Error('ESCAPE: this is not undefined, got: ' + typeof this); + } + `); + await expect(loadPlugin(meta, GROUP_ID, AGENT_ID)).resolves.toBeUndefined(); + }); + }); + + describe('runtime-level shadowing', () => { + it('require is shadowed (blocks CJS imports)', async () => { + const meta = makeMeta({ id: 'require-test' }); + mockPluginCode(` + if (typeof require !== 'undefined') { + throw new Error('ESCAPE: require is accessible'); + } + `); + await expect(loadPlugin(meta, GROUP_ID, AGENT_ID)).resolves.toBeUndefined(); + }); + + it('process is shadowed (blocks env access)', async () => { + const meta = makeMeta({ id: 'process-test' }); + mockPluginCode(` + if (typeof process !== 'undefined') { + throw new Error('ESCAPE: process is accessible'); + } + `); + await expect(loadPlugin(meta, GROUP_ID, AGENT_ID)).resolves.toBeUndefined(); + }); + + it('Deno is shadowed', async () => { + const meta = makeMeta({ id: 'deno-test' }); + mockPluginCode(` + if (typeof Deno !== 'undefined') { + throw new Error('ESCAPE: Deno is accessible'); + } + `); + await expect(loadPlugin(meta, GROUP_ID, AGENT_ID)).resolves.toBeUndefined(); + }); + }); + + describe('Tauri IPC shadowing', () => { + it('__TAURI__ is shadowed (blocks Tauri IPC bridge)', async () => { + const meta = makeMeta({ id: 'tauri-test' }); + mockPluginCode(` + if (typeof __TAURI__ !== 'undefined') { + throw new Error('ESCAPE: __TAURI__ is accessible'); + } + `); + await expect(loadPlugin(meta, GROUP_ID, AGENT_ID)).resolves.toBeUndefined(); + }); + + it('__TAURI_INTERNALS__ is shadowed', async () => { + const meta = makeMeta({ id: 'tauri-internals-test' }); + mockPluginCode(` + if (typeof __TAURI_INTERNALS__ !== 'undefined') { + throw new Error('ESCAPE: __TAURI_INTERNALS__ is accessible'); + } + `); + await expect(loadPlugin(meta, GROUP_ID, AGENT_ID)).resolves.toBeUndefined(); + }); + }); +}); + +// --- Permission-gated API tests --- + +describe('plugin-host permissions', () => { + describe('palette permission', () => { + it('plugin with palette permission can register commands', async () => { + const meta = makeMeta({ id: 'palette-plugin', permissions: ['palette'] }); + mockPluginCode(` + bterminal.palette.registerCommand('Test Command', function() {}); + `); + + await loadPlugin(meta, GROUP_ID, AGENT_ID); + + expect(addPluginCommand).toHaveBeenCalledWith( + 'palette-plugin', + 'Test Command', + expect.any(Function), + ); + }); + + it('plugin without palette permission has no palette API', async () => { + const meta = makeMeta({ id: 'no-palette-plugin', permissions: [] }); + mockPluginCode(` + if (bterminal.palette !== undefined) { + throw new Error('palette API should not be available'); + } + `); + await expect(loadPlugin(meta, GROUP_ID, AGENT_ID)).resolves.toBeUndefined(); + }); + + it('palette.registerCommand rejects non-string label', async () => { + const meta = makeMeta({ id: 'bad-label-plugin', permissions: ['palette'] }); + mockPluginCode(` + bterminal.palette.registerCommand(123, function() {}); + `); + await expect(loadPlugin(meta, GROUP_ID, AGENT_ID)).rejects.toThrow( + 'execution failed', + ); + }); + + it('palette.registerCommand rejects non-function callback', async () => { + const meta = makeMeta({ id: 'bad-cb-plugin', permissions: ['palette'] }); + mockPluginCode(` + bterminal.palette.registerCommand('Test', 'not-a-function'); + `); + await expect(loadPlugin(meta, GROUP_ID, AGENT_ID)).rejects.toThrow( + 'execution failed', + ); + }); + + it('palette.registerCommand rejects empty label', async () => { + const meta = makeMeta({ id: 'empty-label-plugin', permissions: ['palette'] }); + mockPluginCode(` + bterminal.palette.registerCommand(' ', function() {}); + `); + await expect(loadPlugin(meta, GROUP_ID, AGENT_ID)).rejects.toThrow( + 'execution failed', + ); + }); + }); + + describe('API object is frozen', () => { + it('cannot add properties to bterminal', async () => { + const meta = makeMeta({ id: 'freeze-test', permissions: [] }); + // In strict mode, assigning to a frozen object throws TypeError + mockPluginCode(` + try { + bterminal.hacked = true; + throw new Error('FREEZE FAILED: could add property'); + } catch (e) { + if (e.message === 'FREEZE FAILED: could add property') throw e; + // TypeError from strict mode + frozen object is expected + } + `); + await expect(loadPlugin(meta, GROUP_ID, AGENT_ID)).resolves.toBeUndefined(); + }); + + it('cannot delete properties from bterminal', async () => { + const meta = makeMeta({ id: 'freeze-delete-test', permissions: [] }); + mockPluginCode(` + try { + delete bterminal.meta; + throw new Error('FREEZE FAILED: could delete property'); + } catch (e) { + if (e.message === 'FREEZE FAILED: could delete property') throw e; + } + `); + await expect(loadPlugin(meta, GROUP_ID, AGENT_ID)).resolves.toBeUndefined(); + }); + }); +}); + +// --- Lifecycle tests --- + +describe('plugin-host lifecycle', () => { + it('loadPlugin registers the plugin', async () => { + const meta = makeMeta({ id: 'lifecycle-load' }); + mockPluginCode('// no-op'); + + await loadPlugin(meta, GROUP_ID, AGENT_ID); + + const loaded = getLoadedPlugins(); + expect(loaded).toHaveLength(1); + expect(loaded[0].id).toBe('lifecycle-load'); + }); + + it('loadPlugin warns on duplicate load and returns early', async () => { + const meta = makeMeta({ id: 'duplicate-load' }); + mockPluginCode('// no-op'); + + await loadPlugin(meta, GROUP_ID, AGENT_ID); + + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + await loadPlugin(meta, GROUP_ID, AGENT_ID); + expect(consoleSpy).toHaveBeenCalledWith("Plugin 'duplicate-load' is already loaded"); + consoleSpy.mockRestore(); + + // Still only one entry + expect(getLoadedPlugins()).toHaveLength(1); + }); + + it('unloadPlugin removes the plugin and cleans up commands', async () => { + const meta = makeMeta({ id: 'lifecycle-unload', permissions: ['palette'] }); + mockPluginCode(` + bterminal.palette.registerCommand('Cmd1', function() {}); + `); + + await loadPlugin(meta, GROUP_ID, AGENT_ID); + expect(getLoadedPlugins()).toHaveLength(1); + + unloadPlugin('lifecycle-unload'); + expect(getLoadedPlugins()).toHaveLength(0); + expect(removePluginCommands).toHaveBeenCalledWith('lifecycle-unload'); + }); + + it('unloadPlugin is no-op for unknown plugin', () => { + unloadPlugin('nonexistent'); + expect(getLoadedPlugins()).toHaveLength(0); + }); + + it('unloadAllPlugins clears all loaded plugins', async () => { + mockPluginCode('// no-op'); + + const meta1 = makeMeta({ id: 'all-1' }); + await loadPlugin(meta1, GROUP_ID, AGENT_ID); + + const meta2 = makeMeta({ id: 'all-2' }); + await loadPlugin(meta2, GROUP_ID, AGENT_ID); + + expect(getLoadedPlugins()).toHaveLength(2); + + unloadAllPlugins(); + expect(getLoadedPlugins()).toHaveLength(0); + }); + + it('loadPlugin cleans up commands on execution error', async () => { + const meta = makeMeta({ id: 'error-cleanup' }); + mockPluginCode('throw new Error("plugin crash");'); + + await expect(loadPlugin(meta, GROUP_ID, AGENT_ID)).rejects.toThrow( + "Plugin 'error-cleanup' execution failed", + ); + expect(removePluginCommands).toHaveBeenCalledWith('error-cleanup'); + expect(getLoadedPlugins()).toHaveLength(0); + }); + + it('loadPlugin throws on file read failure', async () => { + const meta = makeMeta({ id: 'read-fail' }); + mockInvoke.mockRejectedValue(new Error('file not found')); + + await expect(loadPlugin(meta, GROUP_ID, AGENT_ID)).rejects.toThrow( + "Failed to read plugin 'read-fail'", + ); + }); + + it('plugin meta is accessible and frozen', async () => { + const meta = makeMeta({ id: 'meta-access', permissions: [] }); + mockPluginCode(` + if (bterminal.meta.id !== 'meta-access') { + throw new Error('meta.id mismatch'); + } + if (bterminal.meta.name !== 'Test Plugin') { + throw new Error('meta.name mismatch'); + } + // meta should also be frozen + try { + bterminal.meta.id = 'hacked'; + throw new Error('META FREEZE FAILED'); + } catch (e) { + if (e.message === 'META FREEZE FAILED') throw e; + } + `); + await expect(loadPlugin(meta, GROUP_ID, AGENT_ID)).resolves.toBeUndefined(); + }); +}); diff --git a/v2/src/lib/plugins/plugin-host.ts b/v2/src/lib/plugins/plugin-host.ts new file mode 100644 index 0000000..de32f89 --- /dev/null +++ b/v2/src/lib/plugins/plugin-host.ts @@ -0,0 +1,213 @@ +/** + * Plugin Host — sandboxed runtime for BTerminal plugins. + * + * Plugins run via `new Function()` with a controlled API object (`bterminal`). + * Dangerous globals are shadowed via `var` declarations inside strict mode. + * + * SECURITY BOUNDARY: Best-effort sandbox, NOT a security boundary. + * `new Function()` executes in the same JS realm. Known limitations: + * - `arguments.callee.constructor('return this')()` can recover the real global + * object — this is inherent to `new Function()` and cannot be fully blocked + * without a separate realm (iframe, Worker, or wasm-based isolate). + * - Prototype chain walking (e.g., `({}).constructor.constructor`) can also + * reach Function and thus the global scope. + * - Plugins MUST be treated as UNTRUSTED. This sandbox reduces the attack + * surface but does not eliminate it. Defense in depth comes from the Rust + * backend's Landlock sandbox and permission-gated Tauri commands. + */ + +import type { PluginMeta } from '../adapters/plugins-bridge'; +import { readPluginFile } from '../adapters/plugins-bridge'; +import { listTasks, getTaskComments } from '../adapters/bttask-bridge'; +import { + getUnreadMessages, + getChannels, +} from '../adapters/btmsg-bridge'; +import { + addPluginCommand, + removePluginCommands, + pluginEventBus, +} from '../stores/plugins.svelte'; +import type { GroupId, AgentId } from '../types/ids'; + +interface LoadedPlugin { + meta: PluginMeta; + cleanup: () => void; +} + +const loadedPlugins = new Map(); + +/** + * Build the sandboxed API object for a plugin. + * Only exposes capabilities matching the plugin's declared permissions. + */ +function buildPluginAPI(meta: PluginMeta, groupId: GroupId, agentId: AgentId): Record { + const api: Record = { + meta: Object.freeze({ ...meta }), + }; + + // palette permission — register command palette commands + if (meta.permissions.includes('palette')) { + api.palette = { + registerCommand(label: string, callback: () => void) { + if (typeof label !== 'string' || !label.trim()) { + throw new Error('Command label must be a non-empty string'); + } + if (typeof callback !== 'function') { + throw new Error('Command callback must be a function'); + } + addPluginCommand(meta.id, label, callback); + }, + }; + } + + // bttask:read permission — read-only task access + if (meta.permissions.includes('bttask:read')) { + api.tasks = { + async list() { + return listTasks(groupId); + }, + async comments(taskId: string) { + return getTaskComments(taskId); + }, + }; + } + + // btmsg:read permission — read-only message access + if (meta.permissions.includes('btmsg:read')) { + api.messages = { + async inbox() { + return getUnreadMessages(agentId); + }, + async channels() { + return getChannels(groupId); + }, + }; + } + + // events permission — subscribe to app events + if (meta.permissions.includes('events')) { + const subscriptions: Array<{ event: string; callback: (data: unknown) => void }> = []; + + api.events = { + on(event: string, callback: (data: unknown) => void) { + if (typeof event !== 'string' || typeof callback !== 'function') { + throw new Error('event.on requires (string, function)'); + } + pluginEventBus.on(event, callback); + subscriptions.push({ event, callback }); + }, + off(event: string, callback: (data: unknown) => void) { + pluginEventBus.off(event, callback); + const idx = subscriptions.findIndex(s => s.event === event && s.callback === callback); + if (idx >= 0) subscriptions.splice(idx, 1); + }, + }; + + // Return a cleanup function that removes all subscriptions + const originalCleanup = () => { + for (const sub of subscriptions) { + pluginEventBus.off(sub.event, sub.callback); + } + subscriptions.length = 0; + }; + // Attach to meta for later use + (api as { _eventCleanup?: () => void })._eventCleanup = originalCleanup; + } + + return api; +} + +/** + * Load and execute a plugin in a sandboxed context. + */ +export async function loadPlugin( + meta: PluginMeta, + groupId: GroupId, + agentId: AgentId, +): Promise { + if (loadedPlugins.has(meta.id)) { + console.warn(`Plugin '${meta.id}' is already loaded`); + return; + } + + // Read the plugin's entry file + let code: string; + try { + code = await readPluginFile(meta.id, meta.main); + } catch (e) { + throw new Error(`Failed to read plugin '${meta.id}' entry file '${meta.main}': ${e}`); + } + + const api = buildPluginAPI(meta, groupId, agentId); + + // Execute the plugin code in a sandbox via new Function(). + // The plugin receives `bterminal` as its only external reference. + // No access to window, document, fetch, globalThis, etc. + try { + const sandbox = new Function( + 'bterminal', + // Explicitly shadow dangerous globals. + // `var` declarations in strict mode shadow the outer scope names, + // making direct references resolve to `undefined`. + // See file-level JSDoc for known limitations of this approach. + `"use strict"; + var window = undefined; + var document = undefined; + var fetch = undefined; + var globalThis = undefined; + var self = undefined; + var XMLHttpRequest = undefined; + var WebSocket = undefined; + var Function = undefined; + var importScripts = undefined; + var require = undefined; + var process = undefined; + var Deno = undefined; + var __TAURI__ = undefined; + var __TAURI_INTERNALS__ = undefined; + ${code}`, + ); + // Bind `this` to undefined so plugin code cannot use `this` to reach + // the global scope. In strict mode, `this` remains undefined. + sandbox.call(undefined, Object.freeze(api)); + } catch (e) { + // Clean up any partially registered commands + removePluginCommands(meta.id); + throw new Error(`Plugin '${meta.id}' execution failed: ${e}`); + } + + const cleanup = () => { + removePluginCommands(meta.id); + const eventCleanup = (api as { _eventCleanup?: () => void })._eventCleanup; + if (eventCleanup) eventCleanup(); + }; + + loadedPlugins.set(meta.id, { meta, cleanup }); +} + +/** + * Unload a plugin, removing all its registered commands and event subscriptions. + */ +export function unloadPlugin(id: string): void { + const plugin = loadedPlugins.get(id); + if (!plugin) return; + plugin.cleanup(); + loadedPlugins.delete(id); +} + +/** + * Get all currently loaded plugins. + */ +export function getLoadedPlugins(): PluginMeta[] { + return Array.from(loadedPlugins.values()).map(p => p.meta); +} + +/** + * Unload all plugins. + */ +export function unloadAllPlugins(): void { + for (const [id] of loadedPlugins) { + unloadPlugin(id); + } +}