BTerminal/v2/src/lib/plugins/plugin-host.test.ts
Hibryda 8754b64ee3 fix: track plugin-host source and add 35 sandbox security tests
Fix .gitignore 'plugins/' rule that was accidentally ignoring source
files in v2/src/lib/plugins/. Narrow to /plugins/ and /v2/plugins/
(runtime plugin directories only). Track plugin-host.ts (was written
but never committed) and add comprehensive test suite covering all 13
shadowed globals, this-binding, permission gating, API freeze, and
lifecycle management.
2026-03-12 05:25:12 +01:00

373 lines
12 KiB
TypeScript

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> = {}): 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();
});
});