Add plugin framework and settings UI
This commit is contained in:
221
server/src/services/plugin-runtime-sandbox.ts
Normal file
221
server/src/services/plugin-runtime-sandbox.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
import { existsSync, readFileSync, realpathSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import vm from "node:vm";
|
||||
import type { PaperclipPluginManifestV1 } from "@paperclipai/shared";
|
||||
import type { PluginCapabilityValidator } from "./plugin-capability-validator.js";
|
||||
|
||||
export class PluginSandboxError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "PluginSandboxError";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sandbox runtime options used when loading a plugin worker module.
|
||||
*
|
||||
* `allowedModuleSpecifiers` controls which bare module specifiers are permitted.
|
||||
* `allowedModules` provides concrete host-provided bindings for those specifiers.
|
||||
*/
|
||||
export interface PluginSandboxOptions {
|
||||
entrypointPath: string;
|
||||
allowedModuleSpecifiers?: ReadonlySet<string>;
|
||||
allowedModules?: Readonly<Record<string, Record<string, unknown>>>;
|
||||
allowedGlobals?: Record<string, unknown>;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Operation-level runtime gate for plugin host API calls.
|
||||
* Every host operation must be checked against manifest capabilities before execution.
|
||||
*/
|
||||
export interface CapabilityScopedInvoker {
|
||||
invoke<T>(operation: string, fn: () => Promise<T> | T): Promise<T>;
|
||||
}
|
||||
|
||||
interface LoadedModule {
|
||||
namespace: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 2_000;
|
||||
const MODULE_PATH_SUFFIXES = ["", ".js", ".mjs", ".cjs", "/index.js", "/index.mjs", "/index.cjs"];
|
||||
const DEFAULT_GLOBALS: Record<string, unknown> = {
|
||||
console,
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
setInterval,
|
||||
clearInterval,
|
||||
URL,
|
||||
URLSearchParams,
|
||||
TextEncoder,
|
||||
TextDecoder,
|
||||
AbortController,
|
||||
AbortSignal,
|
||||
};
|
||||
|
||||
export function createCapabilityScopedInvoker(
|
||||
manifest: PaperclipPluginManifestV1,
|
||||
validator: PluginCapabilityValidator,
|
||||
): CapabilityScopedInvoker {
|
||||
return {
|
||||
async invoke<T>(operation: string, fn: () => Promise<T> | T): Promise<T> {
|
||||
validator.assertOperation(manifest, operation);
|
||||
return await fn();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a CommonJS plugin module in a VM context with explicit module import allow-listing.
|
||||
*
|
||||
* Security properties:
|
||||
* - no implicit access to host globals like `process`
|
||||
* - no unrestricted built-in module imports
|
||||
* - relative imports are resolved only inside the plugin root directory
|
||||
*/
|
||||
export async function loadPluginModuleInSandbox(
|
||||
options: PluginSandboxOptions,
|
||||
): Promise<LoadedModule> {
|
||||
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
||||
const allowedSpecifiers = options.allowedModuleSpecifiers ?? new Set<string>();
|
||||
const entrypointPath = path.resolve(options.entrypointPath);
|
||||
const pluginRoot = path.dirname(entrypointPath);
|
||||
|
||||
const context = vm.createContext({
|
||||
...DEFAULT_GLOBALS,
|
||||
...options.allowedGlobals,
|
||||
});
|
||||
|
||||
const moduleCache = new Map<string, Record<string, unknown>>();
|
||||
const allowedModules = options.allowedModules ?? {};
|
||||
|
||||
const realPluginRoot = realpathSync(pluginRoot);
|
||||
|
||||
const loadModuleSync = (modulePath: string): Record<string, unknown> => {
|
||||
const resolvedPath = resolveModulePathSync(path.resolve(modulePath));
|
||||
const realPath = realpathSync(resolvedPath);
|
||||
|
||||
if (!isWithinRoot(realPath, realPluginRoot)) {
|
||||
throw new PluginSandboxError(
|
||||
`Import '${modulePath}' escapes plugin root and is not allowed`,
|
||||
);
|
||||
}
|
||||
|
||||
const cached = moduleCache.get(realPath);
|
||||
if (cached) return cached;
|
||||
|
||||
const code = readModuleSourceSync(realPath);
|
||||
|
||||
if (looksLikeEsm(code)) {
|
||||
throw new PluginSandboxError(
|
||||
"Sandbox loader only supports CommonJS modules. Build plugin worker entrypoints as CJS for sandboxed loading.",
|
||||
);
|
||||
}
|
||||
|
||||
const module = { exports: {} as Record<string, unknown> };
|
||||
// Cache the module before execution to preserve CommonJS cycle semantics.
|
||||
moduleCache.set(realPath, module.exports);
|
||||
|
||||
const requireInSandbox = (specifier: string): Record<string, unknown> => {
|
||||
if (!specifier.startsWith(".") && !specifier.startsWith("/")) {
|
||||
if (!allowedSpecifiers.has(specifier)) {
|
||||
throw new PluginSandboxError(
|
||||
`Import denied for module '${specifier}'. Add an explicit sandbox allow-list entry.`,
|
||||
);
|
||||
}
|
||||
|
||||
const binding = allowedModules[specifier];
|
||||
if (!binding) {
|
||||
throw new PluginSandboxError(
|
||||
`Bare module '${specifier}' is allow-listed but no host binding is registered.`,
|
||||
);
|
||||
}
|
||||
|
||||
return binding;
|
||||
}
|
||||
|
||||
const candidatePath = path.resolve(path.dirname(realPath), specifier);
|
||||
return loadModuleSync(candidatePath);
|
||||
};
|
||||
|
||||
// Inject the CJS module arguments into the context so the script can call
|
||||
// the wrapper immediately. This is critical: the timeout in runInContext
|
||||
// only applies during script evaluation. By including the self-invocation
|
||||
// `(fn)(exports, module, ...)` in the script text, the timeout also covers
|
||||
// the actual module body execution — preventing infinite loops from hanging.
|
||||
const sandboxArgs = {
|
||||
__paperclip_exports: module.exports,
|
||||
__paperclip_module: module,
|
||||
__paperclip_require: requireInSandbox,
|
||||
__paperclip_filename: realPath,
|
||||
__paperclip_dirname: path.dirname(realPath),
|
||||
};
|
||||
// Temporarily inject args into the context, run, then remove to avoid pollution.
|
||||
Object.assign(context, sandboxArgs);
|
||||
const wrapped = `(function (exports, module, require, __filename, __dirname) {\n${code}\n})(__paperclip_exports, __paperclip_module, __paperclip_require, __paperclip_filename, __paperclip_dirname)`;
|
||||
const script = new vm.Script(wrapped, { filename: realPath });
|
||||
try {
|
||||
script.runInContext(context, { timeout: timeoutMs });
|
||||
} finally {
|
||||
for (const key of Object.keys(sandboxArgs)) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
|
||||
delete (context as Record<string, unknown>)[key];
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedExports = normalizeModuleExports(module.exports);
|
||||
moduleCache.set(realPath, normalizedExports);
|
||||
return normalizedExports;
|
||||
};
|
||||
|
||||
const entryExports = loadModuleSync(entrypointPath);
|
||||
|
||||
return {
|
||||
namespace: { ...entryExports },
|
||||
};
|
||||
}
|
||||
|
||||
function resolveModulePathSync(candidatePath: string): string {
|
||||
for (const suffix of MODULE_PATH_SUFFIXES) {
|
||||
const fullPath = `${candidatePath}${suffix}`;
|
||||
if (existsSync(fullPath)) {
|
||||
return fullPath;
|
||||
}
|
||||
}
|
||||
|
||||
throw new PluginSandboxError(`Unable to resolve module import at path '${candidatePath}'`);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when `targetPath` is inside `rootPath` (or equals rootPath), false otherwise.
|
||||
* Uses `path.relative` so sibling-prefix paths (e.g. `/root-a` vs `/root`) cannot bypass checks.
|
||||
*/
|
||||
function isWithinRoot(targetPath: string, rootPath: string): boolean {
|
||||
const relative = path.relative(rootPath, targetPath);
|
||||
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
||||
}
|
||||
|
||||
function readModuleSourceSync(modulePath: string): string {
|
||||
try {
|
||||
return readFileSync(modulePath, "utf8");
|
||||
} catch (error) {
|
||||
throw new PluginSandboxError(
|
||||
`Failed to read sandbox module '${modulePath}': ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeModuleExports(exportsValue: unknown): Record<string, unknown> {
|
||||
if (typeof exportsValue === "object" && exportsValue !== null) {
|
||||
return exportsValue as Record<string, unknown>;
|
||||
}
|
||||
|
||||
return { default: exportsValue };
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight guard to reject ESM syntax in the VM CommonJS loader.
|
||||
*/
|
||||
function looksLikeEsm(code: string): boolean {
|
||||
return /(^|\n)\s*import\s+/m.test(code) || /(^|\n)\s*export\s+/m.test(code);
|
||||
}
|
||||
Reference in New Issue
Block a user