Skip to Content
GuidePermissions

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 + network

The 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.json

learn --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 interfaces

SSH host-key verification

ssh2.Client.connect() fails closed — it no longer accepts any server key. A connection requires one of:

  • hostFingerprint — the SHA256:… ssh-keygen fingerprint of the server key;
  • knownHosts — a path to an OpenSSH known_hosts file (read through the --allow-read permission 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.

package.json
{ "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:

FieldTypeEquivalent CLI flag
allow-readstring[] (paths)--allow-read
allow-writestring[] (paths)--allow-write
allow-netstring[] (hosts, * wildcard)--allow-net
allow-envstring[] (var names)--allow-env
allow-ffistring[] (paths)--allow-ffi
allow-child-processbool--allow-child-process
deny-read / deny-write / deny-net / deny-env / deny-ffistring[]none — package.json-only
deny-child-processboolnone — 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-*: any node_modules/<pkg> frame on the stack applies that package’s deny rules. This covers every builtin, including fetch, 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.bind carry 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.