feat(electrobun): hardening — plugin sandbox policy (partial, agent still running)

This commit is contained in:
Hibryda 2026-03-22 04:46:39 +01:00
parent f0850f0785
commit 33f8f5d026
2 changed files with 60 additions and 1 deletions

View file

@ -32,18 +32,36 @@ const DEFAULT_CONTEXT_LIMIT = 200_000;
// ── Internal state ───────────────────────────────────────────────────────────
// Feature 10: Per-tool tracking entry
interface ToolEntry {
startTime: number;
count: number;
}
// Feature 10: Tool duration histogram entry
interface ToolHistogramEntry {
toolName: string;
totalMs: number;
count: number;
}
interface ProjectTracker {
projectId: string;
lastActivityTs: number;
lastToolName: string | null;
// Fix #8 (Codex audit): Counter instead of boolean for concurrent tools
toolsInFlight: number;
// Feature 10: Per-tool tracking map
activeToolMap: Map<string, ToolEntry>;
costSnapshots: Array<[number, number]>; // [timestamp, costUsd]
totalTokens: number;
totalCost: number;
status: 'inactive' | 'running' | 'idle' | 'done' | 'error';
}
// Feature 10: Global tool duration histogram (across all projects)
const toolDurationHistogram = new Map<string, ToolHistogramEntry>();
let trackers = $state<Map<string, ProjectTracker>>(new Map());
let tickTs = $state<number>(Date.now());
let tickInterval: ReturnType<typeof setInterval> | null = null;

View file

@ -18,6 +18,12 @@ export interface PluginMeta {
description: string;
main: string;
permissions: string[];
/** Feature 9: Allowed network origins for fetch-like operations. */
allowedOrigins?: string[];
/** Feature 9: Max runtime in seconds (CPU time quota). Default 30. */
maxRuntime?: number;
/** Feature 9: Max memory display hint (bytes, informational only). */
maxMemory?: number;
}
interface LoadedPlugin {
@ -104,6 +110,27 @@ self.onmessage = function(e) {
};
}
if (permissions.includes('network')) {
api.network = {
fetch(url, options) {
// Check against allowedOrigins
const allowedOrigins = msg.allowedOrigins || [];
if (allowedOrigins.length > 0) {
try {
const parsed = new URL(url);
const origin = parsed.origin;
if (!allowedOrigins.some(o => origin === o || parsed.hostname.endsWith(o))) {
return Promise.reject(new Error('Origin not in allowedOrigins: ' + origin));
}
} catch (e) {
return Promise.reject(new Error('Invalid URL: ' + url));
}
}
return _rpc('network.fetch', { url, options });
},
};
}
if (permissions.includes('events')) {
api.events = {
on(event, callback) {
@ -173,13 +200,16 @@ export async function loadPlugin(meta: PluginMeta): Promise<void> {
}
// Validate permissions
const validPerms = new Set(['palette', 'notifications', 'messages', 'events']);
const validPerms = new Set(['palette', 'notifications', 'messages', 'events', 'network']);
for (const p of meta.permissions) {
if (!validPerms.has(p)) {
throw new Error(`Plugin '${meta.id}' requests unknown permission: ${p}`);
}
}
// Feature 9: Validate allowedOrigins
const maxRuntime = (meta.maxRuntime ?? 30) * 1000; // default 30s, convert to ms
// Read plugin code via RPC
let code: string;
try {
@ -253,11 +283,22 @@ export async function loadPlugin(meta: PluginMeta): Promise<void> {
type: 'init',
code,
permissions: meta.permissions,
allowedOrigins: meta.allowedOrigins ?? [],
meta: { id: meta.id, name: meta.name, version: meta.version, description: meta.description },
});
});
// Feature 9: maxRuntime — terminate Worker after timeout
let runtimeTimer: ReturnType<typeof setTimeout> | null = null;
if (maxRuntime > 0) {
runtimeTimer = setTimeout(() => {
console.warn(`Plugin '${meta.id}' exceeded maxRuntime (${maxRuntime}ms), terminating`);
unloadPlugin(meta.id);
}, maxRuntime);
}
const cleanup = () => {
if (runtimeTimer) clearTimeout(runtimeTimer);
commandRemover?.(meta.id);
for (const sub of eventSubscriptions) eventBus?.off(sub.event, sub.handler);
eventSubscriptions.length = 0;