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 esrun CLI — 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.

Looking for the how-to?

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.

CapabilityGrants
EnvRead environment, arguments, cwd, platform; backs runtime:process.
FileReadRead files within the configured root jail.
FileWriteWrite files within the configured root jail.
NetOpen outbound network connections (runtime:net connect, fetch, WebSocket).
NetListenBind 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.
SignalsWatch 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.
RunSpawn a child process (runtime:system). Never implied by another capability — see below.
HrTimeAccess 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.

Shell
esrun --deny-net --deny-run app.js                     # everything, minus these
esrun --deny-all --allow-imports --allow-net app.js    # nothing, plus these
ModeBaselineDirection
--deny-<name>everything grantedsubtractive only
--deny-all --allow-<name>nothing grantedadditive only

--allow-<name> requires --deny-all — with everything already granted, there is nothing for it to add.

NameCovers
readruntime:fs / runtime:wasi reads
writeruntime:fs / runtime:wasi mutations
importsimport "./x.js", import "pkg", dynamic import()
netfetch, WebSocket, runtime:net connect
listenruntime:net listen, runtime:http serve
envruntime:process env / args / cwd
runruntime:system child processes
signalsruntime: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):

Shell
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:

WrittenWhy it fails
--deny-run=gitA denial is all-or-nothing — a scope narrows a grant
--allow-env=A,,BAn empty entry in a scope list
--allow-net example.comA value never attaches as a separate word
esrun app.js --deny-netAfter the script it restricts nothing (-- opts out)
--allow-net without --deny-allNothing to add to an already-granted baseline
--allow-ffiNot 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:

JavaScript
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.

Shell
esrun --deny-all --allow-imports --allow-net=db.internal:5432 \
      --import-policy=./import-policy.json server.js
JSON
{ "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.

A policy is not a way around --deny-imports

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.

Known gap: no integrity

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

Run ends the sandbox

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.

No shell

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.

No inherited environment

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.

Policy below the capability

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.

Root jail in action

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/passwdDENIED: 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.

No implicit disk reads

A .env loads only via --env-file. Nothing on disk reaches the environment unless you ask for it.

OS environment wins

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.

Secret masking

Keys ending in _KEY/_TOKEN/_SECRET/_PASS(WORD) or containing CREDENTIAL/AUTH become a Secret that prints as [redacted].

Accidents, not attackers

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.

Last updated on
Edit this page