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.
This commit is contained in:
parent
9af3cdc637
commit
5e949696d5
3 changed files with 588 additions and 1 deletions
373
v2/src/lib/plugins/plugin-host.test.ts
Normal file
373
v2/src/lib/plugins/plugin-host.test.ts
Normal file
|
|
@ -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> = {}): 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();
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue