Permissions
Every capability is blocked by default. Permissions are granted per-invocation via flags and apply equally to application code and all loaded dependencies.
3va run app.ts \
--allow-read=/app/config \ # filesystem read, scoped to a path
--allow-write=/tmp \ # filesystem write, scoped to a path
--allow-net=api.stripe.com \ # outbound network, scoped to a host
--allow-env=DATABASE_URL \ # env var access, scoped to a variable
--allow-child-process \ # spawn child processes
--allow-ffi=./build/addon.node # load native addon (NAPI)Omitting a flag means the capability is blocked and cannot be enabled from inside the script. In an attended terminal (stderr is a TTY), the runtime asks interactively at the point of first access; in CI, pipes, or redirected output, ungranted capabilities are denied silently.
Permission scopes can be widened to cover all values:
3va run app.ts --allow-read --allow-net # unrestricted read + networkThe capability categories are FileRead, FileWrite, Network,
EnvAccess, SpawnProcess, and FFI.
Permission analysis commands
Two commands help derive the minimum permission set for a script:
3va permissions suggest # static analysis of source files → suggested flags
3va permissions learn app.ts # run with all permissions, report which were used
3va permissions learn app.ts --write # …and merge them into package.jsonlearn --write merges the observed grants into the root "." scope of
package.json, keeps existing grants and per-package scopes, and refuses
to overwrite a package.json that isn’t valid JSON.
When a run is refused something, 3va run prints a summary on exit with
the flag that would allow each item. --trace-denials prints each refusal
as it happens. Environment-variable reads are included (read environment variable PORT); enumerating process.env isn’t.
--allow-net accepts host:port: api.example.com:443 allows only that
port, while a bare host allows every port. Every network builtin (fetch,
net, tls, WebSocket, EventSource, dgram, and the FTP, IMAP, POP3,
IRC, MQTT, gRPC and SSH clients) checks the same host:port. A host name is
resolved once, addresses denied by an IP rule are dropped, and the
connection goes only to the addresses that were checked. dns lookups need
a network grant too.
Listening
server.listen(port) without a host asks for all interfaces. That needs a
grant naming them: --allow-net=0.0.0.0, :: or *. With any other
network grant the server binds 127.0.0.1 instead, and 3va prints a note.
server.address() reports the address actually bound. This applies to
HTTP, HTTP/2, TCP and UDP servers.
3va run server.ts --allow-net=api.example.com # listens on 127.0.0.1 only
3va run server.ts --allow-net=api.example.com,0.0.0.0 # listens on all interfacesSSH host-key verification
ssh2.Client.connect() fails closed — it no longer accepts any server key.
A connection requires one of:
hostFingerprint— theSHA256:…ssh-keygen fingerprint of the server key;knownHosts— a path to an OpenSSHknown_hostsfile (read through the--allow-readpermission model);hostVerifier— a callback(fingerprint) => boolean, sync or Promise.
With none of them the connection is refused with EHOSTUNVERIFY, and a
known_hosts entry whose recorded key changed is refused too. Breaking
change: scripts that relied on accepting any key must pass one of the
options above or run with --allow-insecure. The loopback /
--allow-insecure exemption applies only when no explicit option is given —
a supplied hostFingerprint / knownHosts / hostVerifier is always
enforced.
Package-level permission declarations
Besides CLI flags, 3va run reads permission grants from the nearest
package.json (same directory as the entry file) under a "3va" key — a
pattern already established by "jest", "eslint", and "prettier".
Node.js, Bun, pnpm, and Yarn ignore unknown keys, so there is no conflict.
CLI flags and package.json grants are merged, not replaced — CLI flags
only add on top of what package.json already grants; they can never revoke
a package.json grant.
{
"name": "my-app",
"dependencies": {
"express": "^4.18.0",
"axios": "^1.6.0"
},
"3va": {
"no-prompt": true,
"permissions": {
".": {
"allow-net": ["api.example.com"],
"allow-read": ["./config", "${NODE_MODULES_ROOT}/express@4.22.2"],
"deny-read": ["${NODE_MODULES_ROOT}/express@4.22.2/node_modules/express/lib/express.js"]
},
"axios": {
"allow-net": ["api.example.com"]
},
"express": {
"allow-net": ["*"]
}
}
}
}Fields
Per-scope fields mirror the CLI flags, one per capability category:
| Field | Type | Equivalent CLI flag |
|---|---|---|
allow-read | string[] (paths) | --allow-read |
allow-write | string[] (paths) | --allow-write |
allow-net | string[] (hosts, * wildcard) | --allow-net |
allow-env | string[] (var names) | --allow-env |
allow-ffi | string[] (paths) | --allow-ffi |
allow-child-process | bool | --allow-child-process |
deny-read / deny-write / deny-net / deny-env / deny-ffi | string[] | none — package.json-only |
deny-child-process | bool | none — package.json-only |
no-prompt (top-level, outside permissions) | bool | --no-prompt |
Scopes are enforced, not just documentation
Each key under permissions is a scope: "." for the app’s own code
(applies globally, regardless of what’s executing), or a package name.
A dependency you didn’t name gets none of the package-scoped grants.
When a builtin checks a permission, 3va reads the V8 call stack, so JavaScript can’t hide or fake which package is calling:
deny-*: anynode_modules/<pkg>frame on the stack applies that package’s deny rules. This covers every builtin, includingfetch,new net.Socket()and the raw bindings.allow-*: a package-scoped grant applies only when every package on the stack holds it. A package can’t borrow another package’s grant by calling through it.- Deferred work keeps its scopes: timers, promise callbacks and
Function.prototype.bindcarry the scopes of the code that created them.
Stack inspection only runs once a project declares a non-"." scope, so
the common case has no overhead.
deny-* carves exceptions out of a broader grant
The deny-list is consulted before the granted-list, so a deny-* entry
always wins over a broader allow-*, regardless of declaration order. This
is the supported way to grant a whole vendored directory by prefix while
excluding one file with a known CVE:
{
"3va": {
"permissions": {
".": {
"allow-read": ["node_modules/.pnpm/express@4.22.2"],
"deny-read": ["node_modules/.pnpm/express@4.22.2/node_modules/express/lib/express.js"]
}
}
}
}Path resolution
Relative paths resolve against the directory containing package.json, not
the invocation cwd — the same file works from any working directory.
A path is checked against grants after resolving symlinks and .. the way
the kernel does, so a link inside a granted directory can’t reach a file
outside it. On Linux every fs operation then acts on the descriptor that
was checked, so the path can’t be swapped between the check and the use.
node:sqlite needs read and write grants for the database file
(:memory: needs none), and SQL can’t open other files (ATTACH,
VACUUM INTO and VACUUM fail).
${VAR} expansion
${VAR} in path fields expands against the environment of the host process
running 3va run, evaluated before any capability exists. This lets one
absolute root that differs per server/team (/var/node_module,
/local/bin/node_modules, …) be declared once and switched per environment
without editing package.json. An undefined variable is left as a literal
placeholder — it fails closed rather than collapsing to an empty string
(which would otherwise silently widen a scoped grant).
no-prompt
"no-prompt": true is equivalent to passing --no-prompt on every
invocation: any capability not covered by allow-* is denied silently
instead of prompting.