Node Permission Model: Lock Down Production Processes Running Plugins

Node Permission Model: Lock Down Production Processes Running Plugins

Plugins are how product teams ship extensibility and how supply-chain malware gets a free process. Node’s experimental permission model (--permission) finally lets you treat a plugin worker like a jail: declare the paths and hosts it may touch, fail closed on everything else, and keep the parent process’s secrets out of reach. This is not a replacement for container isolation — it is the innermost belt when a plugin already runs inside your address space.

⚡ TL;DR: Boot plugin workers with --permission --allow-fs-read=... --allow-fs-write=... --allow-net=... (and deny child_process by default); never grant the parent process’s home or .env trees; wrap process.permission checks in your plugin loader so missing flags fail CI; pair with Agent Tool Allowlists: Least Privilege for Filesystem and Shell Access for the outer agent sandbox. Measure ERR_ACCESS_DENIED rates in staging before prod.

Why plugins need a different trust boundary

Your API process holds DB creds, IAM role credentials via the instance metadata path, and customer data in memory. A compromised require()d plugin — or a WASM plugin that shells out — inherits all of that unless you split processes and shrink the child’s capability set.

// src/plugin-host.ts — spawn with an explicit allowlist
import { Worker } from "node:worker_threads";
import { fileURLToPath } from "node:url";

const PLUGIN_ROOT = fileURLToPath(new URL("../plugins/sandbox/", import.meta.url));

export function startPluginWorker(entry: string) {
  // ✅ Child gets permission flags; parent does not need them
  return new Worker(entry, {
    execArgv: [
      "--permission",
      `--allow-fs-read=${PLUGIN_ROOT}`,
      `--allow-fs-read=${process.execPath}`,
      `--allow-fs-write=${PLUGIN_ROOT}/tmp`,
      "--allow-net=api.partner.example.com",
      // omit --allow-child-process → denied
    ],
    workerData: { pluginRoot: PLUGIN_ROOT },
  });
}
// ❌ Same process, full FS — "we'll review plugins carefully"
import { createRequire } from "node:module";
const req = createRequire(import.meta.url);
export const plugin = req("./plugins/community/do-stuff.js");

Map capabilities to plugin contracts

Every plugin declares a manifest of paths and hosts. The host validates the manifest against an org policy, then translates it into execArgv. Drift between manifest and flags is how you get silent over-permission.

// src/plugin-manifest.ts
export type PluginManifest = {
  id: string;
  fsRead: string[];
  fsWrite: string[];
  net: string[];
};

const ORG_DENY_NET = new Set(["169.254.169.254", "metadata.google.internal"]);

export function toExecArgv(m: PluginManifest, root: string): string[] {
  for (const p of [...m.fsRead, ...m.fsWrite]) {
    if (!p.startsWith(root)) throw new Error(`path_outside_root:${p}`);
  }
  for (const h of m.net) {
    if (ORG_DENY_NET.has(h) || h.includes("*")) throw new Error(`net_denied:${h}`);
  }
  return [
    "--permission",
    ...m.fsRead.map((p) => `--allow-fs-read=${p}`),
    ...m.fsWrite.map((p) => `--allow-fs-write=${p}`),
    ...m.net.map((h) => `--allow-net=${h}`),
  ];
}

Cross-check the same philosophy used for AI tool runners in Secure AI Sandboxes: Ephemeral ECS Tasks for Agent Tool Execution — declare, allowlist, deny by default.

Fail closed in CI and on boot

If someone deploys without --permission, your “secure” plugin host is theater. Assert the flag at process start and in the image entrypoint test.

// src/assert-permission.ts
export function assertPermissionModelEnabled() {
  if (typeof process.permission === "undefined") {
    throw new Error("NODE_PERMISSION_MODEL_REQUIRED");
  }
  const sneaky = "/etc/passwd";
  if (process.permission.has("fs.read", sneaky)) {
    throw new Error("over_permissioned_fs_read");
  }
}
# .github/workflows/plugin-host.yml
- name: Permission flag smoke
  run: |
    node --permission --allow-fs-read=$PWD/plugins/sandbox \
      --eval "require('./dist/assert-permission.js').assertPermissionModelEnabled()"

Observability for denials

Treat ERR_ACCESS_DENIED as a first-class signal. A spike after a plugin release usually means the manifest is wrong — or the plugin is probing.

// src/plugin-worker-entry.ts
process.on("uncaughtException", (err: NodeJS.ErrnoException) => {
  if (err?.code === "ERR_ACCESS_DENIED") {
    metrics.increment("plugin.permission_denied", {
      plugin: process.env.PLUGIN_ID,
    });
  }
  throw err;
});

Production rollout pattern

  1. Run plugin workers under --permission in staging with verbose denial logs.
  2. Tighten manifests until denials are only malicious or unexpected paths.
  3. Block deploys that omit the flag via entrypoint assert.
  4. Keep network allowlists hostname-exact; never *.amazonaws.com.
  5. Still run the worker in a separate cgroup/task — permissions are defense in depth.

Closing checklist

✅ Dos
– ✅ Spawn plugins in Workers/child processes with --permission
– ✅ Derive flags from a reviewed manifest under a fixed root
– ✅ Assert process.permission in CI and boot
– ✅ Meter ERR_ACCESS_DENIED per plugin id
– ✅ Deny child_process and metadata IPs by default

❌ Don’ts
– ❌ Don’t require() untrusted plugins into the API process
– ❌ Don’t allow-read the repo root, home, or /var/run/secrets
– ❌ Don’t use net wildcards in production
– ❌ Don’t treat the permission model as a substitute for IAM/task roles
– ❌ Don’t ignore denial spikes after plugin upgrades

Related reading

Last updated on September 11, 2026


Discover more from CheatCoders

Subscribe to get the latest posts sent to your email.

Comments

No comments yet. Why don’t you start the discussion?

Leave a Reply