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.

Usage

TEXT
esrun <file>                Run a JavaScript module file
esrun -e=<code>             Run an inline module snippet
esrun --deny-all            Run with no host access at all
esrun --deny-<name>         Deny one capability; repeatable
esrun --allow-<name>        Grant one back; requires --deny-all; repeatable
esrun --allow-<name>=<list> Grant it narrowed to a list (seven of the eight)
esrun --import-policy=<file>
                            JSON policy for what may be loaded
esrun -t=<ms>, --timeout=<ms>
                            Stop execution after <ms> ms (watchdog)
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 types                 Print the runtime: TypeScript definitions
esrun types --install       Install the definitions + wire up tsconfig.json
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.
--deny-allRun with no host access at all. Cannot be combined with --deny-<name>. See Permissions.
--deny-<name>Deny one capability; repeatable. <name> is one of read, write, imports, net, listen, env, run, signals. Cannot be combined with --deny-all.
--allow-<name>Grant one capability back; repeatable. Requires --deny-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. 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.
upgradeDownload the latest release for your platform, verify its checksum, and replace the running binary in place.
typesPrint the runtime: TypeScript definitions to stdout (esrun types > runtime.d.ts) — version-matched and offline.
types --installInstall the definitions into node_modules/@opentf/esrun and wire up tsconfig.json (typeRoots + types) so editors resolve the runtime:* modules automatically.
-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

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

# Run with no host access at all
esrun --deny-all app.js

# Run with everything except the network and subprocesses
esrun --deny-net --deny-run app.js

# Start from nothing and grant back exactly what a server needs
esrun --deny-all --allow-imports --allow-listen --allow-net server.js

# ...and narrow those grants to what it actually touches
esrun --deny-all --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 --deny-all --allow-imports --import-policy=./import-policy.json server.js

Permissions

esrun grants every capability by default. Two modes restrict a run, and they cannot be combined:

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. Neither mode mixes directions, so no flag ever overrides another.

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

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 Runtime walks a working script from --deny-all to a narrowed command, and explains how to read each denial back to the flag that fixes it.

Scoped grants

Seven of the eight --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 --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
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)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. 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 name as written and the resolved file, so git, /usr/bin/git, and git.exe are all the same program.

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. The list also narrows the root jail and never widens it — a path outside the project root stays unreachable, and says so with ERR_JAIL_ESCAPE rather than a scoped denial.

--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 --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 (lodash, @scope/pkg) — the split the loader already makes between a relative and a bare specifier, so there is no second grammar. 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 --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.

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 --deny-netAfter the script it is the script's argument (rule 2)
--allow-net without --deny-allNothing to add to an already-granted baseline
--allow-ffiNot one of the eight
--timeout 500Same rule 1 — this is the parser's grammar, not a permission rule
--deny-all alone runs a single file

The entry file is read before the runtime exists, so --deny-all still runs what you named. It includes --deny-imports, though, so add --allow-imports for an app with dependencies. 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.

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.

Last updated on
Edit this page