Launch OpenGPEX

Security & Isolation

OpenGPEX enforces a layered security model to protect users from malicious or buggy plugins while preserving the flexibility of the plugin system.


Three-Tier Safety Net

Tier 1: Safe Mode (URL Escape Hatch)

If a plugin hangs or crashes the editor at startup, users can bypass it:

http://localhost:3000/?safe-mode=true

In Safe Mode the engine loads only the official core registry (base + community). All statically registered user plugins and all dynamic plugin loading are skipped, along with their shortcuts and commands. The console prints [OpenGPEX Safe Mode] so the user can identify and remove the offending plugin.

Version Gate

At registration (usePluginInit.ts), manifest.requirements.coreVersion is checked against the running engine (CORE_VERSION):

const requiredCore = rawPlugin.manifest?.requirements?.coreVersion;
if (requiredCore && !satisfiesCoreVersion(requiredCore)) {
  console.warn(
    `[PluginInit] Plugin "${uid}" requires core ${requiredCore}, ` +
    `but current core is ${CORE_VERSION}. Plugin will not be loaded.`
  );
  return;
}

The gate rejects the plugin (it is never registered). Supported formats: >=x.y.z, ^x.y.z, and bare x.y.z (parsed by satisfiesCoreVersion in core/plugin/version.ts — unparseable requirements pass in lenient mode). Declare coreVersion: '>=2.0.0' for plugins that rely on v2-only APIs (the v2 AssetService, presets, WebGPU-era pixel services).

Tier 2: UI Error Boundary

Every plugin mount point is wrapped in <PluginErrorBoundary>:

  • If a plugin throws during React render, only that plugin crashes
  • The canvas, toolbars, and other plugins continue normally
  • The crashed region shows a warning placeholder with the plugin ID
  • The user can save work and disable the plugin from the Hub

Tier 3: Command Execution Isolation

All command execution and interceptors are individually try/caught in the core dispatcher:

// Interceptor isolation (per-plugin, in the core dispatcher)
try {
  const scopedContext = createScopedContext(plugin.uid, plugin.initialConfig);
  const isConsumed = commandInterceptor.beforeExecute(id, scopedContext);
  if (isConsumed === true) return undefined as R;
} catch (error) {
  console.error(`[OpenGPEX Error] Interceptor Failed in Plugin ${plugin.uid}:`, error);
}

// Command isolation
try {
  return command.execute(scopedContext, payload as never) as R;
} catch (error) {
  console.error(`[OpenGPEX Error] Command Execution Failed (${id}):`, error);
  return undefined as R; // no crash propagation
}

Trust Levels (sourceType)

Level Meaning Capabilities
base Official core plugins Full access, cannot be disabled
community Verified third-party, pre-installed Full access, user can disable
user User-installed Sandboxed metadata, user can remove

Metadata Sandboxing (Dynamic Plugins)

When a ZIP plugin is dynamically imported, the loader forcibly rewrites and isolates its metadata:

  1. UID recomputed from author + "." + id — prevents hijacking an existing plugin's namespace
  2. sourceType = 'user' — a dynamic plugin can never masquerade as base or community
  3. group = 'gpex.plugins.user.' — forced into the user group for slot grouping and policy

Cloud Mode Restrictions

When running in SaaS mode (GPEX_CLOUD_MODE=true):

  • /api/plugins/upload returns 403 Forbidden (backend physical block)
  • The upload UI is removed entirely
  • Dynamic plugin loading is disabled in addition to upload
  • Pre-installed base and community plugins function normally

This prevents untrusted code from entering the shared multi-tenant environment. Self-host deployments (GPEX_CLOUD_MODE=false) get the full upload/hot-plug workflow; uploaded plugins are persisted to the fixed data/plugins/user directory (PERSISTENT_PLUGINS_DIR in core/helpers/config.ts).


Upload Defense Layers

Defense Check Result
Cloud Mode GPEX_CLOUD_MODE=true 403 Forbidden
Manifest Missing/unparseable plugin.manifest/plugin.json, or missing id/author 400 Bad Request
Namespace id (lowercased) starts with base. or com.gpex.plugins.base. 400 Bad Request
Zip Slip Normalized entry path contains .. or is absolute 400 Bad Request, extraction aborted
Static conflict Same UID as a static user plugin Allowed, returns conflictingStaticUid (static version auto-disabled)

Resource and Data Boundaries

  • usePluginResource resolves strictly inside the plugin's own directory; traversal is rejected by the serve API
  • Plugins read their own config via usePluginSelfConfig() (zero-argument scope resolution)
  • Reading another plugin's config via usePluginConfig(uid) is possible; mutating it directly is discouraged
  • Cross-plugin interaction should flow through the Command Bus (explicit, auditable) and public signals (read-only indicators)

Runtime Environment

Plugins run in the same JavaScript context as the core engine — there is no iframe or Worker sandbox for plugin UI code. Isolation is enforced by the safety net above plus convention:

  • Touching DOM outside the plugin's slot → discouraged
  • Importing store internals directly → discouraged
  • Network access (fetch/XHR) → allowed (standard browser sandbox applies)
  • File system access → browser-restricted (user prompt required)

Heavy or risky computation belongs in a plugin-owned Worker — see Plugin Workers for the fault-isolation boundary.


Best Practices for Plugin Developers

  1. Do not lean on the Error Boundary — wrap known-fallible operations (network, JSON parse) in your own try/catch and surface a toast.
  2. Clean up listeners — every addEventListener in a useEffect needs a matching removal, or the boundary cannot save you from memory exhaustion.
  3. Contain side effects — never mutate state outside your scope (state.frames, other plugins' config, core preferences) from onAction.
  4. Defensive busy release — always release setBusy in a finally block; bind isBusy to submit buttons to prevent double-submission during async work.
  5. Declare coreVersion — a correct floor lets the version gate fail fast instead of crashing on missing v2 APIs.

Next Steps


Last updated: 2026-10-12