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 used3va permissions suggest/learn don’t yet write directly into
package.json — that’s planned, manual editing is required today.
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 —
which only applies while that package’s own code is executing. A
dependency you didn’t name gets none of it, even if it’s also calling
fs/net from the same process.
This is tracked per-thread by vvva_permissions::scope: require() derives
the requesting package’s name from the innermost node_modules/<pkg>
segment of the calling module’s path (anything outside node_modules is
"."), and brackets every call into fs, fs/promises, net, tls,
dgram, and child_process with that scope. Wrapping is skipped entirely
when a project declares no non-"." scopes, so the common case has zero
overhead.
Known residual gap: only plain factory functions are wrapped
(net.connect, createServer, dgram.createSocket, child_process.exec/
spawn). A PascalCase constructor like net.Socket is deliberately left
unwrapped — wrapping a constructor with a closure would break instanceof
— so new require('net').Socket() followed by calling .connect()
manually bypasses scoping.
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.
${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.