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.envtrees; wrapprocess.permissionchecks 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. MeasureERR_ACCESS_DENIEDrates 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
- Run plugin workers under
--permissionin staging with verbose denial logs. - Tighten manifests until denials are only malicious or unexpected paths.
- Block deploys that omit the flag via entrypoint assert.
- Keep network allowlists hostname-exact; never
*.amazonaws.com. - 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
- Agent Tool Allowlists: Least Privilege for Filesystem and Shell Access
- Secure AI Sandboxes: Ephemeral ECS Tasks for Agent Tool Execution
- Claude Code Hooks: Gate Risky Shell Commands Before CI Runs
- AI Code Review Bots: IAM, Secrets, and Least-Privilege Pipelines
Last updated on September 11, 2026
Discover more from CheatCoders
Subscribe to get the latest posts sent to your email.
