Spaces:
Build error
Build error
File size: 7,609 Bytes
cf9339a | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 | 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);
}
|