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
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:
A flag is
--flagor--flag=value. A value is never a separate argument —--timeout=500, not--timeout 500.esrun's flags come before the script. Everything after it belongs to the script, readable as
args.
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
| Option | Description |
|---|---|
<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, -A | Grant every capability. Cannot be combined with --allow-<name> or --deny-all. |
--deny-<name> | Take one capability back; repeatable. Requires --allow-all. |
--deny-all | Grant 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-override | Let --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. |
upgrade | Download the latest release for your platform, verify its checksum, and replace the running binary in place. |
-h, --help | Print usage and exit. |
-v, --version | Print the esrun version and exit. |
Examples
# 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:
| Mode | Baseline | Direction |
|---|---|---|
--allow-<name> | nothing granted (the default) | additive only |
--allow-all --deny-<name> | everything granted | subtractive 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.
| 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, a UDP send |
listen | runtime:net listen and bind, runtime:http serve |
env | runtime:process env / cwd. Not args: that is the command line that started the program |
run | runtime:system child processes |
signals | runtime:process onSignal |
workers | new Worker(...) — what the worker itself may do is granted at the spawn |
diagnostics | runtime:diagnostics — span timings, kinds, counts, the handle inventory, loop metrics. attributes come back empty. |
diagnostics-detail | The 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.
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.
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
| Flag | Grants | Everything else |
|---|---|---|
--allow-read=<paths> | reading those paths and their subtrees | refused |
--allow-write=<paths> | writing those paths and their subtrees | refused 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 variables | absent from env — unreadable and unlistable |
--allow-run=<programs> | spawning those programs | fails to spawn |
--allow-signals=<names> | watching those signals | refused, 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.
--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.
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.
--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.
esrun --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 (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.
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.
"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.
| 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 (rule 1) |
esrun app.js --allow-net | After the script it is the script's argument (rule 2) |
--deny-net without --allow-all | Nothing to take from an empty baseline |
--allow-ffi | Not one of the nine |
--allow-workers=x | workers is all-or-nothing; the scope is set at the spawn |
--timeout 500 | Same rule 1 — this is the parser's grammar, not a permission rule |
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.
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.
esrun app.js # container limit, else host memory esrun --max-heap=512 app.js # pin it
| Applies to | this agent, and as the ceiling every worker it starts inherits |
| A worker may | lower it with new Worker(url, { memory: 64 }) — never raise it |
| Reaching it | ends 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.
| Situation | What happens |
|---|---|
| A server is running | Stop accepting, drain in flight, exit 128 + signal |
| No server is running | Exit immediately — nothing in flight to protect |
| You installed a handler | esrun stays out of the way; the handler owns shutdown |
A second ^C while draining | Exit immediately |
The drain outlasts --shutdown-grace | Exit 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
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.
GET (SERVER, trace 6341c455) ├─ fs_write ./o.txt ├─ fs_remove ./o.txt └─ http_respond fetch http://…/api/users (CLIENT, trace 3bf2e15b)
| Protocol | OTLP/JSON over HTTP, to <url>/v1/traces. Protobuf is not implemented. |
| Attributes | Semantic conventions: file.path, db.query.text, url.full, process.command, http.request.method, url.path. |
| Capability | None. The runtime exports, so your code holds neither net to reach the collector nor diagnostics to read its own traces. |
| Collector down | Logged once, then dropped. It never fails or delays your program. |
| Shutdown | Flushed, 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:
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 subsystem | RUST_LOG=runtime::http=debug |
| Everything, including dependencies | RUST_LOG=debug |
| Off | RUST_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.
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.