esrun CLI

The standalone esrun binary runs a JavaScript ES module file (or an inline snippet) end to end. Inputs run as modules — import/export and top-level await work.

For TypeScript, bundling, tests, watching and a debugger, see the development binary: esdev. It takes every flag below and means the same thing by each of them — with one deliberate exception: esdev grants every capability by default, and esrun grants none.

Usage

TEXT
esrun <file>                Run a JavaScript module file
esrun -e=<code>             Run an inline module snippet
esrun --allow-<name>        Grant one capability; repeatable
esrun --allow-<name>=<list> Grant it narrowed to a list (seven of the nine)
esrun --allow-all, -A       Grant everything (unsandboxed)
esrun --deny-<name>         Take one back; requires --allow-all; repeatable
esrun --deny-all            Grant nothing — the default, said outright
esrun --import-policy=<file>
                            JSON policy for what may be loaded
esrun -t=<ms>, --timeout=<ms>
                            Stop execution after <ms> ms (watchdog)
esrun --max-heap=<mb>       Heap ceiling in megabytes, for this agent and as the
                            ceiling its workers inherit (default: from the host)
esrun --env-file=<path>     Load env vars from a .env file
esrun --env-override        Let --env-file values override the OS environment
esrun --shutdown-grace=<ms> How long in-flight HTTP requests may finish after
                            ^C/SIGTERM (default 10000)
esrun upgrade               Update esrun to the latest release
esrun -h, --help            Show this help
esrun -v, --version         Show the version

Argument grammar

Two rules, applied to every flag:

  1. A flag is --flag or --flag=value. A value is never a separate argument — --timeout=500, not --timeout 500.

  2. esrun's flags come before the script. Everything after it belongs to the script, readable as args.

Shell
esrun --timeout=500 app.js build --watch
#     └─ esrun's ──┘ └file┘ └─ the script's ─┘

Both are enforced, not conventions: a value that arrives as a separate word would be indistinguishable from the script path, and a flag written after the script would silently do nothing. -- after the script suppresses rule 2 for a script that genuinely wants a flag esrun also knows.

Options

OptionDescription
<file>Path to a JavaScript ES module to run. Resolved as a local file (relative/absolute path or file: URL).
-e=<code>, --eval=<code>Run an inline module snippet instead of a file. Everything after <code> is passed to the script as arguments.
-t=<ms>, --timeout=<ms>Watchdog: stop execution after <ms> milliseconds. Useful for bounding untrusted or long-running scripts.
--max-heap=<mb>Heap ceiling in megabytes for this agent, and the ceiling the workers it starts inherit. Default: the container's memory limit when there is one, else the host's memory. See Memory.
--allow-<name>Grant one capability; repeatable. <name> is one of read, write, imports, net, listen, env, run, signals, workers, diagnostics, diagnostics-detail. See Permissions.
--allow-all, -AGrant every capability. Cannot be combined with --allow-<name> or --deny-all.
--deny-<name>Take one capability back; repeatable. Requires --allow-all.
--deny-allGrant nothing — the default, said outright. Cannot be combined with --allow-all.
--allow-<name>=<list>Grant it narrowed to a comma-separated list: read/write (paths), net/listen (addresses), run (programs), env (variable names), signals (signal names). imports takes no list — see --import-policy — and neither does workers, whose scope is set at the spawn. See Scoped grants.
--import-policy=<file>A JSON file of "allow" and/or "deny" lists of package names and paths, bounding what the module loader may resolve. Never auto-discovered. A second layer, not a substitute for the imports capability. See Import policy.
--env-file=<path>Load environment variables from a single .env file into runtime:process env. No auto-discovery — a file is read only when passed. The OS environment wins on a conflict. Secret-bearing keys (*_KEY, *_TOKEN, *_SECRET, *_PASSWORD, *CREDENTIAL*, *AUTH*) are masked.
--env-overrideLet --env-file values override the OS environment (default: OS wins).
--shutdown-grace=<ms>How long in-flight HTTP requests may finish after ^C/SIGTERM before the process exits anyway. Default 10000. See Graceful shutdown.
--otel[=<url>]Export OpenTelemetry traces to a collector. Bare, it uses http://localhost:4318. The runtime exports — your program needs no capability and no code. See Telemetry.
--otel-service=<name>service.name on exported telemetry. Default: the entry file's name.
--otel-min-duration=<ms>Drop spans shorter than this before exporting.
--otel-sample=<0..1>Fraction of traces to export. Per trace, so a trace is kept whole or dropped whole.
upgradeDownload the latest release for your platform, verify its checksum, and replace the running binary in place.
-h, --helpPrint usage and exit.
-v, --versionPrint the esrun version and exit.

Examples

Shell
# Run a module file
esrun app.js

# Inline snippet (top-level await works)
esrun -e='console.log(await Promise.resolve(42))'

# Pass arguments through to the script (read via runtime:process)
esrun app.js build --watch

# Stop a runaway script after 500ms
esrun -t=500 app.js

# Load env vars from a .env file (OS env wins; --env-override flips it)
esrun --env-file=.env app.js

# The directory you run in is the sandbox: resolution and the filesystem jail
# are both anchored there
cd /app && esrun dist/server.js

# Give in-flight requests 30s to finish after SIGTERM
esrun --shutdown-grace=30000 server.js

# Run with no host access at all — the default
esrun app.js

# Grant exactly what a server needs
esrun --allow-imports --allow-listen --allow-net server.js

# ...and narrow those grants to what it actually touches
esrun --allow-imports --allow-listen=8080 \
      --allow-net=db.internal:5432 --allow-env=PORT,DATABASE_URL \
      --allow-read=./public --allow-write=./var/log \
      --allow-signals=SIGTERM server.js

# Bound what the module loader may resolve, too
esrun --allow-imports --import-policy=./import-policy.json server.js

# Grant everything except the network and subprocesses
esrun --allow-all --deny-net --deny-run app.js

Permissions

esrun grants nothing by default. A run reaches what the command line that started it named, and nothing else. Two modes widen it, and they cannot be combined:

ModeBaselineDirection
--allow-<name>nothing granted (the default)additive only
--allow-all --deny-<name>everything grantedsubtractive only

--deny-<name> requires --allow-all — with nothing granted, there is nothing for it to take away. Neither mode mixes directions, so no flag ever overrides another.

--deny-all restates the default, and is worth writing on a deploy line so a reader need not know which way a binary defaults. esdev defaults the other way — everything granted — and there --allow-all is the no-op.

NameCovers
readruntime:fs / runtime:wasi reads
writeruntime:fs / runtime:wasi mutations
importsimport "./x.js", import "pkg", dynamic import()
netfetch, WebSocket, runtime:net connect, a UDP send
listenruntime:net listen and bind, runtime:http serve
envruntime:process env / cwd. Not args: that is the command line that started the program
runruntime:system child processes
signalsruntime:process onSignal
workersnew Worker(...) — what the worker itself may do is granted at the spawn
diagnosticsruntime:diagnostics — span timings, kinds, counts, the handle inventory, loop metrics. attributes come back empty.
diagnostics-detailThe above plus span attributes (paths, URLs, SQL text). Implies diagnostics.

A denied operation throws NotAllowedError (ERR_CAPABILITY_DENIED) before the effect happens. Importing a runtime: module always works — the gate is the operation, not the import.

Building the command for a real deployment

Securing the runtime walks a working script to a narrowed command, and explains how to read each denial back to the flag that fixes it. esdev --trace-permissions app.js does it in one command.

Scoped grants

Seven of the nine --allow-<name> flags take a comma-separated list that narrows the grant instead of handing over the whole capability. imports is the exception — what may be loaded has its own mechanism.

Shell
esrun --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
FlagGrantsEverything else
--allow-read=<paths>reading those paths and their subtreesrefused
--allow-write=<paths>writing those paths and their subtreesrefused before anything is created
--allow-net=<hosts>reaching those addresses (fetch, connect, WebSocket, every UDP destination)refused before any packet
--allow-listen=<addresses>binding those addresses (listen, serve)refused before the port is claimed
--allow-env=<names>those environment variablesabsent from env — unreadable and unlistable
--allow-run=<programs>spawning those programsfails to spawn
--allow-signals=<names>watching those signalsrefused, and hidden from signals()

An address is a host (any port), a host:port, or a bare port (any interface). Bracket IPv6 that carries a port: [::1]:8080. Matching is exact — example.com does not admit api.example.com, and there are no wildcards — and hosts are judged as written, before resolution. A name entry bounds the name, not the machine — the zone's owner picks the address, on every connection — so write the address where that matters. net and listen keep separate lists: reaching out and being reachable are separate capabilities.

A path is absolute or relative to the working directory — ./data means what it means in the shell you typed it in, not what it means to the script — and covers its subtree, matched by component, so ./app never admits ./app-secrets. read and write are separate lists, and both govern runtime:fs and runtime:wasi alike.

--allow-run=git matches the real path the name resolves to, so git, /usr/bin/git, and git.exe are all the same program — and /tmp/git, a different program under the same name, is not.

A path list is checked after canonicalization

--allow-read=./data refuses ./data/link-to-etc/passwd: the check runs on the real path, so a symlink inside an allowed directory cannot name a file outside it. Inside the root jail the list narrows; an entry outside it adds that subtree, which is how a run reaches a TLS certificate or a CA bundle the project does not contain. Only a path typed here widens it, never guest code, and a path neither inside nor named is ERR_JAIL_ESCAPE.

--allow-net covers every redirect hop

A 302 from an allowed host to a denied one fails the request with ERR_PERMISSION_DENIED. HTTP clients follow redirects on a policy set once per client, so an allowlist checked only where you wrote the URL would follow that hop transparently and hand you the denied host's body.

The value grammar is the same for every capability that takes a list: entries are comma-separated and trimmed (--allow-env="A, B"--allow-env=A,B), an empty entry (a,,b) is an error, and repeating a flag unions its entries. Granting one capability both whole and narrowed (--allow-env --allow-env=HOME) is an error rather than a precedence rule — no flag overrides another here either.

A scoped grant is still a grant

--allow-env=HOME reports permissions.has("env") === true. The capability is what opens the door; the list is what the provider then declines to hand over. A refusal is ERR_PERMISSION_DENIED ("you have env, but not that one"), where --deny-env throws ERR_CAPABILITY_DENIED ("you never had env").

A signal entry is a signal name, and unlisted signals are hidden from signals() — a program should enumerate what it may use.

A value on a flag that could not enforce it would still be rejected rather than ignored; that rule holds for any capability added later.

Import policy

Capabilities answer what may executing code reach. Which modules may become executing code is a different question, and it has its own mechanism — --import-policy=<file>, a JSON file named explicitly and never auto-discovered.

Shell
esrun --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 (lodash, @scope/pkg) — the split the loader already makes between a relative and a bare specifier, so there is no second grammar. A module matches if either kind names it, so a path entry pointing inside node_modules governs what is there. Deny wins over allow. Omitting "allow" permits everything not denied, which is the shape for a policy that only excludes a few packages; an empty "allow": [] is an error rather than a run that can load nothing, and so is an unknown key.

Paths resolve relative to the policy file, not the working directory — a policy is committed next to the project it governs and means the same thing wherever the run is invoked from. Matching runs on the resolved, canonicalized module (after the root jail), so 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 says nothing about the packages it imports, so a dependency cannot quietly bring another along. The entry file is exempt: it is read before a loader exists, and you named it.

A policy is not a way around a missing imports grant

Two layers, not two alternatives: the imports capability decides whether the loader runs at all, the policy decides what it may resolve. Without --allow-imports, an allow entry still loads nothing.

A policy names packages, 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; content pinning is future work.

Strict by design

Each of these is an error. For the permission flags the reason is sharper than tidiness: ignoring one leaves a run wider than the command line claims.

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 (rule 1)
esrun app.js --allow-netAfter the script it is the script's argument (rule 2)
--deny-net without --allow-allNothing to take from an empty baseline
--allow-ffiNot one of the nine
--allow-workers=xworkers is all-or-nothing; the scope is set at the spawn
--timeout 500Same rule 1 — this is the parser's grammar, not a permission rule
With no flags, a run is a single file

The entry file is read before the runtime exists, so a run with nothing granted still runs what you named. imports is denied with everything else, though, so add --allow-imports for an app with dependencies — or ship a bundle, which has none left to resolve. Query the policy from JS with permissions.

Arguments

Anything after the file (or after the -e code) is the script's own argument list, readable as args from runtime:process. The runtime binary and the script path are excluded.

Memory

The heap ceiling is sized from the machine — the cgroup limit when there is one, else the host's memory. Node and Deno both read physical memory here, which is why deploying either means hardcoding --max-old-space-size: in a 2 GiB container on a 64 GiB host they size for 64 GiB and get OOM-killed where a garbage collection would have done.

Shell
esrun app.js                   # container limit, else host memory
esrun --max-heap=512 app.js    # pin it
Applies tothis agent, and as the ceiling every worker it starts inherits
A worker maylower it with new Worker(url, { memory: 64 }) — never raise it
Reaching itends that agent; a worker's parent gets ERR_WORKER_OUT_OF_MEMORY

Graceful shutdown

^C and SIGTERM are handled for you when a runtime:http server is running: esrun stops accepting, lets in-flight requests answer, and exits 130 / 143.

SituationWhat happens
A server is runningStop accepting, drain in flight, exit 128 + signal
No server is runningExit immediately — nothing in flight to protect
You installed a handleresrun stays out of the way; the handler owns shutdown
A second ^C while drainingExit immediately
The drain outlasts --shutdown-graceExit anyway (default 10000ms)

Install a handler with onSignal when you have your own cleanup — closing a pool, flushing a buffer — and esrun leaves the whole shutdown to you.

Telemetry

Shell
esrun --otel app.js                                   # localhost:4318
esrun --otel=http://collector:4318 --otel-service=checkout app.js

Your program does not change. An inbound request becomes a SERVER span with the work it caused nested under it; an outbound fetch becomes a CLIENT span in its own trace.

TEXT
GET                       (SERVER, trace 6341c455)
├─ fs_write   ./o.txt
├─ fs_remove  ./o.txt
└─ http_respond

fetch  http://…/api/users (CLIENT, trace 3bf2e15b)
ProtocolOTLP/JSON over HTTP, to <url>/v1/traces. Protobuf is not implemented.
AttributesSemantic conventions: file.path, db.query.text, url.full, process.command, http.request.method, url.path.
CapabilityNone. The runtime exports, so your code holds neither net to reach the collector nor diagnostics to read its own traces.
Collector downLogged once, then dropped. It never fails or delays your program.
ShutdownFlushed, so a short run does not lose its trace.

A trivial request produces around twenty spans, most of them microsecond-long pure computation. Bound it:

Shell
esrun --otel --otel-min-duration=5 --otel-sample=0.01 app.js

To read spans inside your program instead, use runtime:diagnostics with --allow-diagnostics. The two are independent: exporting does not grant your code the ability to subscribe.

Logging

esrun writes structured tracing events to stderr, separate from your program's console output. RUST_LOG sets the filter.

Default (unset)warn — quiet unless a listening socket is failing
Everything from one subsystemRUST_LOG=runtime::http=debug
Everything, including dependenciesRUST_LOG=debug
OffRUST_LOG=off

Targets are runtime::http, runtime::net and runtime::websocket. Colour is used only when stderr is a terminal, and NO_COLOR turns it off.

Shell
RUST_LOG=runtime::http=debug esrun server.js

The level split is about who can cause an event, not how bad it is. An accept failure is the listening socket's problem and an operator's to act on, so it is warn and visible by default. A failed TLS or WebSocket handshake is something any peer can produce on demand — reporting those by default would let a scanner sweeping a public port set your log volume — so they are debug. A connection that is served and closed cleanly logs nothing at all.

Turn debug on when a server is accepting connections and serving nothing: that is what a certificate no client will accept looks like from this side, and without the log it is indistinguishable from a port nobody is calling. See Internals: the HTTP server for the full event list.

RUST_LOG is the name tracing-subscriber reads, so embedding the runtime as a Rust library and running it under esrun use the same filter syntax and the same target names.

Last updated on
Edit this page