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
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:
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. |
--deny-all | Run 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-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. |
upgrade | Download the latest release for your platform, verify its checksum, and replace the running binary in place. |
types | Print the runtime: TypeScript definitions to stdout (esrun types > runtime.d.ts) — version-matched and offline. |
types --install | Install the definitions into node_modules/@opentf/esrun and wire up tsconfig.json (typeRoots + types) so editors resolve the runtime:* modules automatically. |
-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 # 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:
| 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. Neither mode mixes directions, so no flag ever overrides another.
| 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 |
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 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.
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
| 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) | 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. 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.
--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.
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 --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 (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.
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.
"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 --deny-net | After the script it is the script's argument (rule 2) |
--allow-net without --deny-all | Nothing to add to an already-granted baseline |
--allow-ffi | Not one of the eight |
--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 --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.
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.
| 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.