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:
Hibryda 2026-03-12 05:25:12 +01:00
parent e46b9e06d1
commit 8754b64ee3
3 changed files with 588 additions and 1 deletions

3
.gitignore vendored
View file

@ -4,7 +4,8 @@ __pycache__/
/CLAUDE.md
v2/target/
debug/
plugins/
/plugins/
/v2/plugins/
projects/
.playwright-mcp/
.audit/

View 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();
});
});

View file

@ -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<string, LoadedPlugin>();
/**
* 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<string, unknown> {
const api: Record<string, unknown> = {
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<void> {
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);
}
}