Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ jobs:
- windows-latest
node-version:
- 26.5.1
- 26.8.1
- 26.x

steps:
Expand All @@ -41,6 +42,12 @@ jobs:
- name: Install development dependencies
run: npm ci

- name: Verify Node capability policy
run: npm run test:node-capabilities

- name: Verify capability candidate generation
run: npm run generate:node-capability-candidate > capability-candidate.json

- name: Test
run: npm test

Expand Down
9 changes: 5 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ Each worker starts with Node's Permission Model enabled. Source-string workers r

## Requirements

- Node.js 26.5.1 through the current Node.js 26.x release (`process.permission.drop()` and security fixes in 26.5.1 are required). Unsupported runtimes fail closed during module initialization with `ERR_SECURE_EVAL_UNSUPPORTED_RUNTIME`. Future major releases require a new hardening review before support is declared.
- Node.js 26.5.1 through the current Node.js 26.x release (`process.permission.drop()` and security fixes in 26.5.1 are required). Unsupported runtimes fail closed during module initialization with `ERR_SECURE_EVAL_UNSUPPORTED_RUNTIME`. A Node 26 update with an unreviewed built-in identity or export surface fails worker startup; future major releases require a new hardening review before support is declared. See the [Node capability policy](docs/node-capability-policy.md).
- No permission flags are required when the host uses Node's default mode. If the host itself runs with `--permission`, it must include `--allow-worker`. Path-based execution additionally requires host read permission for the entry/root and read/write permission for the operating-system temporary directory used to create and remove the private snapshot. Each sandbox worker is started with its own reviewed `execArgv`.

## Installation
Expand Down Expand Up @@ -113,7 +113,7 @@ console.log(await component.request('hello')) // { echo: 'hello' }
await component.terminate()
```

Module source must be self-contained ESM with a default setup-function export. It is imported from an in-memory `data:` URL after permissions are dropped. Built-in imports are permitted subject to the Permission Model, but relative files, package imports, and filesystem module paths are not resolved from source strings. Use the path APIs below or bundle authorized dependencies on the trusted host as described in [`docs/module-dependencies.md`](docs/module-dependencies.md).
Module source must be self-contained ESM with a default setup-function export. It is imported from an in-memory `data:` URL after permissions are dropped. Built-in imports are permitted only when approved by the [Node capability policy](docs/node-capability-policy.md), and remain subject to module-specific attenuation and the Permission Model; relative files, package imports, and filesystem module paths are not resolved from source strings. Use the path APIs below or bundle authorized dependencies on the trusted host as described in [`docs/module-dependencies.md`](docs/module-dependencies.md).

### Local module files

Expand Down Expand Up @@ -289,17 +289,18 @@ Errors originating from execution use this class and have a machine-readable `co
- Do not place secrets in `worker_threads.setEnvironmentData()`: Node clones global worker environment data into new workers independently of `WorkerOptions.env`. This module blocks the public getter, heap-snapshot APIs, and `v8.queryObjects()`, but avoiding the secret entirely is safer against future or internal APIs.
- The token-recovery technique used by [Heapjack](https://www.accomplish.ai/blog/escaping-the-openai-codex-sandbox-twice/) does not provide a command path here. Host capabilities run in a different V8 isolate; ordinary stdout is discarded rather than parsed as control traffic; and the worker uses a private `MessageChannel` plus native-backed HMAC `KeyObject`s instead of JavaScript bearer-token strings. The trusted bootstrap and guest still share the worker isolate, so before guest execution the module also denies every callable V8 snapshot/object-query namespace and both inspector APIs exposed by the supported runtime, failing worker startup if those surfaces cannot be replaced. This protects against the published Heapjack technique, not against a future Node/V8 vulnerability or native memory-safety failure; use the outer OS boundary described above for hostile multi-tenant code.
- Both execution modes close and hide `parentPort`, freeze the private channel's privileged prototype chain, and authenticate serialized payloads with per-session HMAC keys and monotonic sequence numbers. Capturing or replaying a port cannot forge control traffic. Captured descriptor operations inspect each protocol field and untrusted error field as an own data property; missing fields never fall through to inherited properties, and metadata accessors are not invoked. The intrinsic stack formatter is used only for verified native stack shapes while global `Error` remains an own data property resolving to the captured constructor and the original `Error.prepareStackTrace` descriptor remains unchanged; otherwise `remoteStack` is omitted. Parser errors created synchronously by captured Node APIs are normalized before guest execution so useful source locations remain available without treating later guest accessors as trusted. Ongoing ordinary reads are limited to private lexical bookkeeping records whose complete own data-property shapes are created by the bootstrap, frozen null-prototype deferred records, and `workerData` whose own security configuration, manifests, and nested policies are frozen before guest execution; the guest input remains intentionally mutable but is never used as trusted bookkeeping.
- Before guest execution, the bootstrap disables known same-process escape surfaces not covered by permissions: existing-descriptor access through public modules, including undocumented child-process IPC adoption; WASI and FFI; DNS and legacy network-module aliases; global `fetch`/`WebSocket`; accessor-exported stream constructors; and undocumented native bindings. It also disables asynchronous module-loader registration; `node:sqlite`; OpenSSL engine/FIPS mutation and secure-heap telemetry; trace/QUIC built-ins (explicitly denied when available and otherwise left unavailable without enabling runtime flags); process signaling, reports, and high-resolution host-lifetime clocks; process priority mutation; guest-facing V8 and performance hooks, serializers, profilers, snapshot callbacks, flag mutation, and object queries; async hooks; BroadcastChannel, Web Locks, cross-thread messaging, inherited worker environment data, and host identity/resource metadata APIs. The only guest-visible high-resolution clocks are worker-bootstrap-relative: a frozen null-prototype performance object shared by the global and built-in aliases exposes monotonic `now()`, `timeOrigin: 0`, and `nodeTiming: null`, while `Event.prototype.timeStamp` and inherited event timestamps use the same relative baseline. The original Performance constructors, prototypes, and observer paths are unavailable. Guest `argv`, current-directory reporting, executable arguments, executable path, process IDs, process title, and global module search paths are replaced with fixed or empty virtual values. Eval-wrapper `require`, `module`, `exports`, `__filename`, and `__dirname` globals are removed; CommonJS modules inside a staged local tree retain only root-confined lexical loading and resolver paths. The read-only `v8.startupSnapshot.isBuildingSnapshot()` probe remains available because Node's TypeScript erasure depends on it; callable V8 capabilities are otherwise denied. Node exposes `process.argv0` as a non-configurable worker property, so it remains visible through global, ESM, CommonJS, and `process.getBuiltinModule()` aliases; launch the host with a non-sensitive `argv0` value when this metadata matters.
- Before guest execution, the bootstrap verifies a reviewed fail-closed Node built-in identity/export manifest, installs a trusted synchronous resolve hook, and independently guards `_load()` and `process.getBuiltinModule()`. Unapproved built-ins fail through static and dynamic ESM, CommonJS, VM default-loader, and direct retrieval paths. Approved modules are either intentionally available, mode-dependent, or attenuated. The bootstrap then disables known same-process escape surfaces not covered by permissions: existing-descriptor access through public modules, including undocumented child-process IPC adoption; WASI and FFI; DNS and legacy network-module aliases; global `fetch`/`WebSocket`; accessor-exported stream constructors; and undocumented native bindings. It also disables asynchronous module-loader registration; `node:sqlite`; OpenSSL engine/FIPS mutation and secure-heap telemetry; trace/QUIC built-ins (explicitly denied when available and otherwise left unavailable without enabling runtime flags); process signaling, reports, and high-resolution host-lifetime clocks; process priority mutation; guest-facing V8 and performance hooks, serializers, profilers, snapshot callbacks, flag mutation, and object queries; async hooks; BroadcastChannel, Web Locks, cross-thread messaging, inherited worker environment data, and host identity/resource metadata APIs. The only guest-visible high-resolution clocks are worker-bootstrap-relative: a frozen null-prototype performance object shared by the global and built-in aliases exposes monotonic `now()`, `timeOrigin: 0`, and `nodeTiming: null`, while `Event.prototype.timeStamp` and inherited event timestamps use the same relative baseline. The original Performance constructors, prototypes, and observer paths are unavailable. Guest `argv`, current-directory reporting, executable arguments, executable path, process IDs, process title, and global module search paths are replaced with fixed or empty virtual values. Eval-wrapper `require`, `module`, `exports`, `__filename`, and `__dirname` globals are removed; CommonJS modules inside a staged local tree retain only root-confined lexical loading and resolver paths. The read-only `v8.startupSnapshot.isBuildingSnapshot()` probe remains available because Node's TypeScript erasure depends on it; callable V8 capabilities are otherwise denied. Node exposes `process.argv0` as a non-configurable worker property, so it remains visible through global, ESM, CommonJS, and `process.getBuiltinModule()` aliases; launch the host with a non-sensitive `argv0` value when this metadata matters.
- Host functions are authority grants. They must validate and authorize every argument, constrain outputs, and avoid exposing generic filesystem or network primitives when a narrower business operation is possible.
- A timed-out worker is sent a termination request, but Worker resource limits do not constrain `ArrayBuffer`, WebAssembly, native allocations, aggregate CPU, all libuv-thread-pool work, or process-wide out-of-memory failure. Uninterruptible native work can continue after the public termination promises settle with a termination error; its admission slot remains occupied until the actual worker exit. Host functions that ignore their abort signal may also continue host-side work after termination.
- JavaScript taming is additional defense in depth, not a substitute for an OS boundary. Future Node APIs, undocumented internals, native/runtime vulnerabilities, or process-wide behavior can invalidate it. Run adversarial multi-tenant code in a separately sandboxed process or container.
- JavaScript taming is additional defense in depth, not a substitute for an OS boundary. Unknown built-in IDs, top-level exports, reviewed direct nested objects, and exported constructor/prototype changes now fail closed, but semantic changes to existing APIs, APIs reachable only through native accessors, undocumented internals, native/runtime vulnerabilities, or process-wide behavior can still invalidate the review. Run adversarial multi-tenant code in a separately sandboxed process or container.

## Development

```sh
npm ci
npm test
npm run test:coverage
npm run test:node-capabilities
npm run test:types
npm run test:package
```
Expand Down
6 changes: 4 additions & 2 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,8 +144,10 @@ features.
These remain intentionally open because they are release-by-release
requirements rather than one-time deliverables.

- [ ] Run the complete test suite on Node.js 26.5.1 and current 26.x for every
security-sensitive change.
- [ ] Run the complete test suite and `npm run test:node-capabilities` on
Node.js 26.5.1, the reviewed 26.8 API boundary, and current 26.x for every security-sensitive change. Review
every identity/export diff before updating the manifest; never auto-accept a
new runtime surface.
- [ ] Add exploit-focused regression coverage for callable accessors, aliases,
prototype-reachable constructors, inherited descriptors, native bindings,
runtime introspection, and alternate execution contexts.
Expand Down
12 changes: 8 additions & 4 deletions docs/module-dependencies.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,11 @@ path and package authorization, graph limits, cancellation, and final output
limits. Review bundler transformations and runtime helpers as executable guest
code. Bundling does not make a dependency trusted.

Do not implement either model with `module.register()`, `registerHooks()`,
`--experimental-loader`, process-wide loader hooks, guest-provided resolvers,
or another VM/worker execution context. Loader hooks can execute outside this
worker's permission-drop and hardening sequence.
Do not implement either model with guest-controlled `module.register()`,
`registerHooks()`, `--experimental-loader`, process-wide loader hooks,
guest-provided resolvers, or another VM/worker execution context. Asynchronous
loader hooks can execute outside this worker's permission-drop and hardening
sequence. The bootstrap installs one private synchronous resolve hook after
permission drop and hardening solely to enforce the reviewed built-in-module
policy; guest registration APIs remain disabled. See
[`node-capability-policy.md`](node-capability-policy.md).
Loading
Loading