Security model
ES Runtime ships in two forms, and they differ on what code can reach by default:
Embeddable library — deny-by-default. A runtime the host creates can compute, but cannot reach the host environment, filesystem, or network until the host grants a capability for it.
The
esrunCLI — unrestricted by default. The standalone binary grants all capabilities so scripts run without setup. It is not deny-by-default; restrict a run with denial flags.
This page is the model — what the pieces are and why. To take a working script and lock it down step by step, read Securing Runtime.
Capabilities
Every host operation declares the capability it requires. The check lives on the native op, not in JavaScript, so it cannot be bypassed by reaching a different module path. A denied capability will instantly throw a standard DOMException with the NotAllowedError name.
| Capability | Grants |
|---|---|
Env | Read environment, arguments, cwd, platform; backs runtime:process. |
FileRead | Read files within the configured root jail. |
FileWrite | Write files within the configured root jail. |
Net | Open outbound network connections (runtime:net connect, fetch, WebSocket). |
NetListen | Bind a listening socket and accept connections (runtime:net listen, runtime:http / runtime:websocket serve). Server-side TLS terminates under this capability — the cert/key are passed inline, so no extra grant is needed. |
Signals | Watch OS signals (runtime:process onSignal). Separate from Env because a watch suppresses the signal's default action — the privilege to decline to die on request, not a read of process state. |
Run | Spawn a child process (runtime:system). Never implied by another capability — see below. |
HrTime | Access high-resolution timing. |
Importing a runtime: module never needs a capability. The gate is the op, so a built-in imports under any policy and only its operations throw.
Denying capabilities in esrun
Two modes, each with a single direction. They cannot be combined, so no flag ever overrides another.
esrun --deny-net --deny-run app.js # everything, minus these esrun --deny-all --allow-imports --allow-net app.js # nothing, plus these
| Mode | Baseline | Direction |
|---|---|---|
--deny-<name> | everything granted | subtractive only |
--deny-all --allow-<name> | nothing granted | additive only |
--allow-<name> requires --deny-all — with everything already granted, there is nothing for it to add.
| Name | Covers |
|---|---|
read | runtime:fs / runtime:wasi reads |
write | runtime:fs / runtime:wasi mutations |
imports | import "./x.js", import "pkg", dynamic import() |
net | fetch, WebSocket, runtime:net connect |
listen | runtime:net listen, runtime:http serve |
env | runtime:process env / args / cwd |
run | runtime:system child processes |
signals | runtime:process onSignal |
--deny-all still runs the entry file — it is read before the runtime exists. It includes --deny-imports, so add --allow-imports for an app with dependencies.
Seven of the eight can be granted narrowed to a list (imports is the exception — what may be loaded is its own mechanism):
esrun --deny-all --allow-imports --allow-env=PORT,DATABASE_URL \ --allow-net=db.internal:5432 --allow-listen=8080 \ --allow-read=./data --allow-write=./out --allow-run=git \ --allow-signals=SIGTERM server.js
--allow-net is the one that stops an exfiltration — a compromised dependency reaching out over the app's own legitimate network access has to reach an address you named — and it is enforced on every redirect hop, not just the URL the program wrote. Unlisted environment variables are absent, so a guest can neither read them nor learn their names; unlisted programs fail to spawn; unlisted binds are refused before the port is claimed; unlisted paths are refused after canonicalization, so a symlink inside an allowed directory cannot name a file outside it; unlisted signals cannot be watched and are hidden from signals().
Matching is exact: example.com does not admit api.example.com, there are no wildcards, and hosts are judged as written rather than resolved. A refusal is ERR_PERMISSION_DENIED, a scoped denial distinct from the ERR_CAPABILITY_DENIED a missing capability gives. A scoped grant still reports permissions.has("net") === true: the capability opens the door, the list is what the provider withholds.
A path list narrows the root jail below and never widens it: a path outside the project root stays unreachable, and says so as a jail escape rather than a scoped denial.
A value on a flag that could not enforce it would still be rejected rather than ignored — a run must never be narrower on the command line than it is in reality, and that holds for any capability added later.
The argument grammar is enforced for the same reason. Each of these is an error, not a warning:
| Written | Why it fails |
|---|---|
--deny-run=git | A denial is all-or-nothing — a scope narrows a grant |
--allow-env=A,,B | An empty entry in a scope list |
--allow-net example.com | A value never attaches as a separate word |
esrun app.js --deny-net | After the script it restricts nothing (-- opts out) |
--allow-net without --deny-all | Nothing to add to an already-granted baseline |
--allow-ffi | Not one of the eight |
A sandbox that silently isn't one is worse than no sandbox, so the parser never guesses what a malformed flag meant.
Ask from JS:
import { permissions } from "runtime:process"; permissions.denied; // ["read", "write"] permissions.has("net"); // false
Import policy
Capabilities bound what running code may reach. What may become running code is a separate question, with a separate mechanism: a JSON file, named by --import-policy and never auto-discovered.
esrun --deny-all --allow-imports --allow-net=db.internal:5432 \ --import-policy=./import-policy.json server.js
{ "allow": ["./src", "express", "@acme/ui"], "deny": ["aws-sdk"] }
An entry beginning with . or / is a path covering its subtree; anything else is a package name — the split the loader already makes between a relative and a bare specifier. Deny wins over allow. Omitting "allow" permits everything not denied; an empty "allow": [] is an error rather than a run that can load nothing, and so is an unknown key — a misspelled "allowed" would otherwise read as protection that is not there.
Paths resolve relative to the policy file, so a committed policy means the same thing wherever the run is invoked from. Matching runs on the resolved, canonicalized module, after the root jail: a symlink cannot name its way in, and a pnpm store path is still recognisably its package. A package entry covers that package's own files and not the packages it imports, so a dependency that quietly pulls in another cannot load it. The entry file is exempt — it is read before a loader exists.
The policy also binds import.meta.resolve: a package it refuses cannot be located, not just not imported — otherwise the refusal would still hand out the path.
Two layers, not two alternatives: the imports capability decides whether the loader runs at all, the policy decides what it may resolve. Under --deny-all, an allow entry still loads nothing.
A policy names packages and paths, not content. "express" says the loader may resolve that package; it says nothing about which version, or whether the bytes are the ones you audited. Lockfiles remain the install-time counterpart, and content pinning is future work — treat the policy as a bound on which dependencies can run, not as proof of what they are.
Child processes
A child process runs outside every confinement here: no capability check, no root jail, no execution deadline reaches it. Granting Run to guest code grants everything the host user can do.
runtime:system has no exec, no shell: true, and no template form. A command is a program plus an argv, so a guest-supplied argument is data and can never become a second command. Windows .bat/.cmd files are refused rather than run through the command interpreter.
A child gets exactly the env it is passed. Inheriting is opt-in and needs Env as well as Run, so Run alone cannot launder the host's environment out through a child.
An embedder that must grant Run can still bound it: the default provider takes an allowlist of programs and a cap on concurrent children, both enforced in Rust. Children still running at shutdown are killed, not orphaned.
The filesystem root jail
Filesystem access — including module resolution — is confined to a single project root. Paths are canonicalized to their real location before the check, so a symlink cannot be used to escape the jail. This is on by default and is not currently optional.
Untrusted JS calls file("../../etc/passwd") → the FileRead capability check and Root Jail check run in the trusted Rust host → the path canonicalizes to /etc/passwd → DENIED: path escapes the project root (/home/user/project).
Environment files & secret masking
What the guest can read from the environment is an explicit host decision, and secret values resist accidental disclosure.
A .env loads only via --env-file. Nothing on disk reaches the environment unless you ask for it.
Loaded values fill only unset keys by default, so a checked-in file can't clobber production config. The real process env is never mutated.
Keys ending in _KEY/_TOKEN/_SECRET/_PASS(WORD) or containing CREDENTIAL/AUTH become a Secret that prints as [redacted].
Masking prevents leaks into logs and JSON. It is not a sandbox — guest code can call unmask() itself.
Remote modules disabled
esrun intentionally drops support for downloading modules dynamically over the network (e.g., import "https://..."). This mitigates entire classes of supply-chain attacks and runtime hijacking because every piece of executed code must explicitly reside within the secure local filesystem root, greatly improving predictability and security.
Engine confinement
All V8 contact is contained in a single engine crate; the rest of the runtime never names a V8 type. This keeps the trusted surface small and auditable, and lets the host drive the event loop without surrendering control of its own thread.