Skip to main content

Core principles

The capability system is built on five principles that govern every access decision Sentinel makes:

Package-level grants

Capabilities are granted to an npm package as a whole. Every node type, module, and callback inside that package operates under those grants. The system is not “node A talking to node B” — it is “package authorised to access resource R”.

Default deny

A package that has not been granted a capability cannot perform the operation. Sentinel blocks and logs the attempt, printing the exact grant needed.

Own-node exemption

A node is always allowed to operate on itself — its own context, its own send, its own status. Capabilities only gate cross-package access.

Entity:sub-resource:operation naming

Every capability follows a consistent naming scheme: node:credentials:read, fs:write, events:listen:flows:started. The entity, sub-resource, and operation are separated by colons.
The own-node exemption means a node reading this.credentials in its own constructor does not require a capability grant — Sentinel only proxies nodes returned from getNode(), not a node’s own this. Granting node:credentials:read signals operator intent and enables auditing, not just access control.

Capability naming scheme

Capabilities follow the entity:sub-resource:operation pattern:
CapabilityEntitySub-resourceOperation
node:credentials:readnodecredentialsread
node:wires:writenodewireswrite
node:context:readnodecontextread
fs:readfsread
fs:writefswrite
network:httpnetworkhttp
process:execprocessexec
process:env:readprocessenvread
hooks:on-sendhookson-send
registry:registerregistryregister
events:listen:flows:startedeventslisten:flows:started
Not every capability has all three segments — shorter forms like fs:read are complete capability strings, not shorthands.

The node:* group and the dual-axis check

The node:* capability group gates what a package can do to node objects in the runtime — things like reading properties, calling methods, sending messages, or accessing credentials. These are enforced when any code in the package calls RED.nodes.getNode(id) and operates on the returned proxy.
The node:* group is the only capability group with a dual-axis check. Both axes must pass for access to be granted:
  • Caller side — does the calling package have the capability in its grants (settings.sentinel.allow or the packages section of .sentinel-grants.json)?
  • Target side — does the target node type allow this operation on its instances (the nodeTypes section of .sentinel-grants.json)?
All other capability groups (fs:*, network:*, process:*, etc.) only have the caller side. The dual-axis model gives node-type authors control over who can access their nodes, independent of what the calling package has been granted.
The full node:* reference:
CapabilityWhat it gates
node:readRead any public property (id, type, name, z, custom fields) and call any public method not covered by a more specific cap. Without it the node is fully opaque.
node:writeSet arbitrary properties via assignment (node.prop = value)
node:sendCall thatNode.send(msg) — inject a message into the flow attributed to that node
node:statusCall thatNode.status({...}) — change the node’s visual badge in the editor
node:logCall thatNode.log(), thatNode.warn(), thatNode.error() — forge log entries attributed to another node
node:closeCall close() to shut down the node
node:receiveCall receive(msg) or emit('input', msg) to inject a message directly into the node’s input handler
node:events:onCall on(event, fn) on the node — registers a persistent listener
node:events:remove-listenersCall removeAllListeners() / removeListener() on the node’s EventEmitter
node:listRED.nodes.eachNode() — iterate over all nodes in the runtime
node:wires:readRead node.wires — the output wire topology
node:wires:writeCall updateWires(wires) — rewire the node’s outputs
node:credentials:readRead node.credentials / getCredentials(id)
node:credentials:writeWrite node.credentials / addCredentials(id, creds)
node:credentials:deletedeleteCredentials(id)
node:context:readCall thatNode.context().get(key) / thatNode.context().keys() via a getNode() reference
node:context:writeCall thatNode.context().set(key, value) via a getNode() reference

Shorthands

Shorthands expand one level to a set of granular capabilities. The resolver is single-level — nested shorthands must be listed explicitly in parent expansions.
ShorthandExpands to
node:eventsnode:events:on + node:events:remove-listeners
node:wiresnode:wires:read + node:wires:write
node:credentialsnode:credentials:read + node:credentials:write + node:credentials:delete
node:contextnode:context:read + node:context:write
node:allAll node:* capabilities
flows:allflows:read + flows:write + flows:delete + flows:start + flows:stop
hooks:messageAll 7 message pipeline hooks (excludes hooks:remove)
hooks:allAll hooks:* capabilities including hooks:remove
fs:allfs:read + fs:write
network:allnetwork:http + network:fetch + network:socket + network:dns + network:listen
process:envprocess:env:read + process:env:write
process:allprocess:exec + process:env:read + process:env:write + process:exit
allEvery capability — the nuclear option. Never use in production.
all includes vm:execute and threads:spawn, both of which escape Module._load hook coverage entirely. Code running inside a vm context or worker thread is invisible to Sentinel.

How Sentinel identifies the calling package

Every capability check needs to know which package is making the call. Sentinel identifies the caller by walking the V8 call stack:
  1. Read new Error().stack and split it into frames.
  2. Skip frames belonging to Node-RED core (@node-red/*, node-red), Express, Node.js internals, and Sentinel itself.
  3. Find the first remaining frame whose file path falls inside the Node-RED userDir — specifically {userDir}/node_modules/ or {userDir}/nodes/.
  4. Extract the package name from the node_modules/<pkg> segment of that path.
The match is against the npm package name exactly as it appears on disk. This mechanism works because the V8 call stack is generated by the runtime and cannot be spoofed from JavaScript running in the same context.
// Example call stack for fs.readFileSync called from a user package:
fs.readFileSync              ← built-in (skipped)
sentinel guard wrapper       ← Sentinel itself (skipped)
node-red-contrib-my-node/index.js:42   ← first userDir frame → "node-red-contrib-my-node"
@node-red/runtime/lib/...   ← trusted NR core (skipped)

Package vs node-type granularity

A single npm package can register many node types, but all of them share the same package name in the call stack. Sentinel cannot distinguish my-package/nodes/foo.js from my-package/nodes/bar.js at the frame level — both resolve to my-package.
If you need different capability levels for different node types, publish each trust boundary as its own scoped package. When a user installs the parent package, npm (v7+) hoists the children to the top level of node_modules/, and Sentinel sees each child’s package name independently:
// settings.js
sentinel: {
    allow: {
        "@my-company/node-data-formatter": ["registry:register"],
        "@my-company/node-mqtt-enricher":  ["registry:register", "node:credentials:read"],
        "@my-company/node-flow-auditor":   ["registry:register", "node:list", "node:wires:read"],
    },
}

Build docs developers (and LLMs) love