Skip to content

Commit e3b2d0d

Browse files
authored
Merge pull request #12 from rivet-dev/ralph/kernel-hardening
chore: kernel hardening
2 parents fa3a802 + ff386f9 commit e3b2d0d

229 files changed

Lines changed: 10909 additions & 3577 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.agent/contracts/node-stdlib.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,13 @@ The `crypto` module SHALL be classified as Stub (Tier 3). `getRandomValues()` an
130130

131131
#### Scenario: Calling crypto.subtle.digest
132132
- **WHEN** sandboxed code calls `crypto.subtle.digest("SHA-256", data)`
133-
- **THEN** the call MUST throw an error indicating subtle crypto is not supported in sandbox
133+
- **THEN** the call MUST delegate to host `node:crypto` and return an ArrayBuffer containing the hash
134+
135+
#### Scenario: crypto.subtle operations delegate to host
136+
- **WHEN** sandboxed code calls any `crypto.subtle` method (digest, encrypt, decrypt, sign, verify, generateKey, importKey, exportKey)
137+
- **THEN** the operation MUST delegate to host `node:crypto` via the `_cryptoSubtle` bridge ref
138+
- **AND** all cryptographic material MUST be transferred as base64-encoded JSON across the isolate boundary
139+
- **AND** CryptoKey objects in the sandbox MUST be opaque wrappers holding serialized key data
134140

135141
### Requirement: Unimplemented Module Tier Assignments
136142
The following modules SHALL be classified as Deferred (Tier 4): `net`, `tls`, `readline`, `perf_hooks`, `async_hooks`, `worker_threads`, `diagnostics_channel`. The following modules SHALL be classified as Unsupported (Tier 5): `dgram`, `http2` (full), `cluster`, `wasi`, `inspector`, `repl`, `trace_events`, `domain`.

CLAUDE.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,16 @@
5555
- track development friction in `docs-internal/friction.md` (mark resolved items with fix notes)
5656
- see `.agent/contracts/README.md` for the full contract index
5757

58+
## Shell & Process Behavior (POSIX compliance)
59+
60+
- the interactive shell (brush-shell via WasmVM) and kernel process model must match POSIX behavior unless explicitly documented otherwise
61+
- `node -e <code>` must produce stdout/stderr visible to the user, both through `kernel.exec()` and in the interactive shell PTY — identical to running `node -e` on a real Linux terminal
62+
- `node -e <invalid>` must display the error (SyntaxError/ReferenceError) on stderr, not silently swallow it
63+
- commands that only read stdin when stdin is a TTY (e.g. `tree`, `cat` with no args) must not hang when run from the shell; commands must detect whether stdin is a real data source vs an empty pipe/PTY
64+
- Ctrl+C (SIGINT) must interrupt the foreground process group within 1 second, matching POSIX `isig` + `VINTR` behavior — this applies to all runtimes (WasmVM, Node, Python)
65+
- signal delivery through the PTY line discipline → kernel process table → driver kill() chain must be end-to-end tested
66+
- when adding or fixing process/signal/PTY behavior, always verify against the equivalent behavior on a real Linux system
67+
5868
## Compatibility Project-Matrix Policy
5969

6070
- compatibility fixtures live under `packages/secure-exec/tests/projects/` and MUST be black-box Node projects (`package.json` + source entrypoint)
@@ -63,6 +73,13 @@
6373
- the matrix runs each fixture in host Node and secure-exec and compares normalized `code`, `stdout`, and `stderr`
6474
- no known-mismatch classification is allowed; parity mismatches stay failing until runtime/bridge behavior is fixed
6575

76+
## Tested Package Tracking
77+
78+
- the Tested Packages section in `docs/nodejs-compatibility.mdx` lists all packages validated via the project-matrix test suite
79+
- when adding a new project-matrix fixture, add the package to the Tested Packages table
80+
- when removing a fixture, remove the package from the table
81+
- the table links to GitHub Issues for requesting new packages to be tracked
82+
6683
## Test Structure
6784

6885
- `tests/test-suite/{node,python}.test.ts` are integration suite drivers; `tests/test-suite/{node,python}/` hold the shared suite definitions
@@ -97,6 +114,8 @@ Follow the style in `packages/secure-exec/src/index.ts`.
97114
- `docs/runtimes/python.mdx` — update when PythonRuntime options/behavior changes
98115
- `docs/system-drivers/node.mdx` — update when createNodeDriver options change
99116
- `docs/system-drivers/browser.mdx` — update when createBrowserDriver options change
117+
- `docs/nodejs-compatibility.mdx` — update when bridge, polyfill, or stub implementations change; keep the Tested Packages section current when adding or removing project-matrix fixtures
118+
- `docs/cloudflare-workers-comparison.mdx` — update when secure-exec capabilities change; bump "Last updated" date
100119

101120
## Backlog Tracking
102121

docs/comparison/cloudflare-workers.mdx

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ description: Node.js API compatibility comparison between secure-exec and Cloudf
55
icon: "scale-balanced"
66
---
77

8-
*Last updated: 2026-03-10*
8+
*Last updated: 2026-03-18*
99

1010
## Overview
1111

@@ -40,8 +40,8 @@ All three CF deployment models share the same `nodejs_compat` API surface. WfP a
4040

4141
| Module | secure-exec | CF Workers (`nodejs_compat`) | Notes |
4242
| --- | --- | --- | --- |
43-
| **`fs`** | 🟡 Core I/O: `readFile`, `writeFile`, `appendFile`, `open`, `read`, `write`, `close`, `readdir`, `mkdir`, `rmdir`, `rm`, `unlink`, `stat`, `lstat`, `rename`, `copyFile`, `exists`, `createReadStream`, `createWriteStream`, `writev`, `access`, `realpath`. Missing: `cp`, `glob`, `opendir`, `mkdtemp`, `statfs`, `readv`, `fdatasync`, `fsync`. Deferred: `watch`, `watchFile`, `chmod`, `chown`, `link`, `symlink`, `readlink`, `truncate`, `utimes`. Full coverage planned. | 🟡 In-memory VFS only. `/bundle` (read-only), `/tmp` (writable, ephemeral per-request), `/dev` devices. Missing: `watch`, `watchFile`, `globSync`, file permissions/ownership. All operations synchronous regardless of API style. Timestamps frozen to Unix epoch. 128 MB max file size. | **secure-exec**: Permission-gated; filesystem behavior determined by system driver (host FS or VFS). Read-only `/app/node_modules` overlay. **CF**: No persistent storage; `/tmp` contents isolated per request and lost after response; no real permissions or ownership. |
44-
| **`http`** | 🟡 `request`, `get`, `createServer` with bridged request/response classes. Fetch-based, fully buffered. No connection pooling, no keep-alive tuning, no WebSocket upgrade, no trailer headers. `Agent` is stub-only. | 🟡 `request`, `get`, `createServer` via fetch API wrapper. Requires extra compat flags. No `Connection` headers, no `Expect: 100-continue`, no socket-level events (`socket`, `upgrade`), no 1xx responses, no trailer headers. `Agent` is stub-only. | |
43+
| **`fs`** | 🟢 Core I/O: `readFile`, `writeFile`, `appendFile`, `open`, `read`, `write`, `close`, `readdir`, `mkdir`, `rmdir`, `rm`, `unlink`, `stat`, `lstat`, `rename`, `copyFile`, `exists`, `createReadStream`, `createWriteStream`, `writev`, `access`, `realpath`, `cp`, `glob`, `opendir`, `mkdtemp`, `statfs`, `readv`, `fdatasync`, `fsync`, `chmod`, `chown`, `link`, `symlink`, `readlink`, `truncate`, `utimes`. Deferred: `watch`, `watchFile`. | 🟡 In-memory VFS only. `/bundle` (read-only), `/tmp` (writable, ephemeral per-request), `/dev` devices. Missing: `watch`, `watchFile`, `globSync`, file permissions/ownership. All operations synchronous regardless of API style. Timestamps frozen to Unix epoch. 128 MB max file size. | **secure-exec**: Permission-gated; filesystem behavior determined by system driver (host FS or VFS). Read-only `/app/node_modules` overlay. **CF**: No persistent storage; `/tmp` contents isolated per request and lost after response; no real permissions or ownership. |
44+
| **`http`** | 🟡 `request`, `get`, `createServer` with bridged request/response classes. Fetch-based, fully buffered. `Agent` with connection pooling and per-host `maxSockets` limits. HTTP upgrade (101 Switching Protocols) support. Trailer header support on `IncomingMessage`. No keep-alive tuning, no WebSocket data framing. | 🟡 `request`, `get`, `createServer` via fetch API wrapper. Requires extra compat flags. No `Connection` headers, no `Expect: 100-continue`, no socket-level events (`socket`, `upgrade`), no 1xx responses, no trailer headers. `Agent` is stub-only. | |
4545
| **`https`** | 🟡 Same contract and limitations as `http`. | 🟡 Same wrapper model and limitations as `http`. | |
4646
| **`http2`** | 🔴 Compatibility classes only; `createServer`/`createSecureServer` throw. | 🔴 Non-functional stub. | Neither platform supports HTTP/2 server creation. |
4747
| **`net`** | 🔵 Planned. | 🟡 `net.connect()` / `net.Socket` for outbound TCP via Cloudflare Sockets API. No `net.createServer()`. | **CF**: Outbound TCP connections supported. **secure-exec**: On roadmap. |
@@ -56,7 +56,7 @@ All three CF deployment models share the same `nodejs_compat` API surface. WfP a
5656
| **`process`** | 🟢 `env` (permission-gated), `cwd`/`chdir`, `exit`, timers, stdio event emitters, `hrtime`, `platform`, `arch`, `version`, `argv`, `pid`, `ppid`, `uid`, `gid`. | 🟡 `env`, `cwd`/`chdir`, `exit`, `nextTick`, `stdin`/`stdout`/`stderr`, `platform`, `arch`, `version`. No real process IDs or OS-level user/group IDs. Requires extra `enable_nodejs_process_v2` flag for full surface. | **secure-exec**: Configurable timing mitigation (`freeze` mode); real `pid`/`uid`/`gid` metadata. **CF**: Synthetic process metadata. |
5757
| **`child_process`** | 🟢 `spawn`, `spawnSync`, `exec`, `execSync`, `execFile`, `execFileSync`. `fork` unsupported. | 🔴 Non-functional stub; all methods throw. | **secure-exec**: Bound to the system driver; subprocess behavior determined by driver implementation. CF has no subprocess support. |
5858
| **`os`** | 🟢 `platform`, `arch`, `type`, `release`, `version`, `homedir`, `tmpdir`, `hostname`, `userInfo`, `os.constants`. | 🟡 Basic platform/arch metadata. | **secure-exec**: Richer OS metadata surface. |
59-
| **`worker_threads`** | ⛔ Stubs that throw on API call. | 🔴 Non-functional stub. | Neither platform supports worker threads. |
59+
| **`worker_threads`** | 🔴 Requireable; all APIs throw deterministic unsupported errors. | 🔴 Non-functional stub. | Neither platform supports worker threads. |
6060
| **`cluster`** |`require()` throws. | 🔴 Non-functional stub. | Neither platform supports clustering. |
6161
| **`timers`** | 🟢 `setTimeout`, `clearTimeout`, `setInterval`, `clearInterval`, `setImmediate`, `clearImmediate`. | 🟢 Same surface; returns `Timeout` objects. | Equivalent support. |
6262
| **`vm`** | 🔴 Browser polyfill via `Function()`/`eval()`. No real context isolation; shares global scope. | 🔴 Non-functional stub. | Neither offers real `vm` sandboxing. secure-exec polyfill silently runs code in shared scope, not safe for isolation. |
@@ -91,13 +91,13 @@ All three CF deployment models share the same `nodejs_compat` API surface. WfP a
9191
| **`events`** | 🟢 Supported. | 🟢 Supported. | |
9292
| **`module`** | 🟢 `createRequire`, `Module` basics, builtin resolution. | 🟡 Limited surface. | **secure-exec**: CJS/ESM with `createRequire`. |
9393
| **`console`** | 🟢 Circular-safe bounded formatting; drop-by-default with `onStdio` hook. | 🟢 Supported; output routed to Workers Logs / Tail Workers. | |
94-
| **`async_hooks`** | ⚪ TBD. | 🔴 Non-functional stub. | |
95-
| **`perf_hooks`** | ⚪ TBD. | 🟡 Limited surface. | |
96-
| **`diagnostics_channel`** | ⚪ TBD. | 🟢 Supported. | |
97-
| **`readline`** | ⚪ TBD. | 🔴 Non-functional stub. | |
94+
| **`async_hooks`** | 🔴 Stub: `AsyncLocalStorage` (run/enterWith/getStore/disable/exit), `AsyncResource` (runInAsyncScope/emitDestroy), `createHook` (returns enable/disable no-ops), `executionAsyncId`/`triggerAsyncId`. All methods are callable but do not track real async context. | 🔴 Non-functional stub. | |
95+
| **`perf_hooks`** | 🔴 Requireable stub; APIs throw deterministic unsupported errors. | 🟡 Limited surface. | |
96+
| **`diagnostics_channel`** | 🔴 Stub: `channel()`, `hasSubscribers()`, `tracingChannel()`, `Channel` constructor. All channels report no subscribers; `publish` is a no-op. Sufficient for framework compatibility (e.g., Fastify). | 🟢 Supported. | |
97+
| **`readline`** | 🔴 Requireable stub; APIs throw deterministic unsupported errors. | 🔴 Non-functional stub. | |
9898
| **`tty`** | 🔴 `isatty()` returns `false`; `ReadStream`/`WriteStream` throw. | 🔴 Stub-like. | Both platforms are essentially non-functional beyond `isatty()`. |
9999
| **`constants`** | 🟢 Supported. | 🟢 Supported. | |
100-
| **`punycode`** | Not listed. | 🟢 Supported (deprecated). | |
100+
| **`punycode`** | 🟢 Supported via `node-stdlib-browser` polyfill (deprecated upstream). | 🟢 Supported (deprecated). | |
101101

102102
### Unsupported in Both
103103

docs/nodejs-compatibility.mdx

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ Unsupported modules use: `"<module> is not supported in sandbox"`.
3939
| `buffer` | 2 (Polyfill) | Polyfill via `buffer`. |
4040
| `url` | 2 (Polyfill) | Polyfill via `whatwg-url` and node-stdlib-browser shims. |
4141
| `events` | 2 (Polyfill) | Polyfill via `events`. |
42-
| `stream` | 2 (Polyfill) | Polyfill via `readable-stream`. |
42+
| `stream` | 2 (Polyfill) | Polyfill via `readable-stream`. `stream/web` subpath supported (Web Streams API: `ReadableStream`, `WritableStream`, `TransformStream`, etc.). |
4343
| `util` | 2 (Polyfill) | Polyfill via node-stdlib-browser. |
4444
| `assert` | 2 (Polyfill) | Polyfill via node-stdlib-browser. |
4545
| `querystring` | 2 (Polyfill) | Polyfill via node-stdlib-browser. |
@@ -53,7 +53,8 @@ Unsupported modules use: `"<module> is not supported in sandbox"`.
5353
| `constants` | 2 (Polyfill) | `constants-browserify`; `os.constants` remains available via `os`. |
5454
| Fetch globals (`fetch`, `Headers`, `Request`, `Response`) | 1 (Bridge) | Bridged via network bridge implementation. |
5555
| `async_hooks` | 3 (Stub) | `AsyncLocalStorage` (with `run`, `enterWith`, `getStore`, `disable`, `exit`), `AsyncResource` (with `runInAsyncScope`, `emitDestroy`), `createHook` (returns enable/disable no-ops), `executionAsyncId`, `triggerAsyncId`. |
56-
| `diagnostics_channel` | 3 (Stub) | No-op `channel()` and `tracingChannel()` stubs; channels always report `hasSubscribers: false`; `publish`, `subscribe`, `unsubscribe` are no-ops. Provides Fastify compatibility. |
56+
| `console` | 1 (Bridge) | Circular-safe bounded formatting via bridge shim; `log`, `warn`, `error`, `info`, `debug`, `dir`, `time`/`timeEnd`/`timeLog`, `assert`, `clear`, `count`/`countReset`, `group`/`groupEnd`, `table`, `trace`. Drop-by-default; consumers use `onStdio` hook for streaming. |
57+
| `diagnostics_channel` | 3 (Stub) | No-op `channel()`, `tracingChannel()`, `Channel` constructor; channels always report `hasSubscribers: false`; `publish`, `subscribe`, `unsubscribe` are no-ops. Provides Fastify compatibility. |
5758
| Deferred modules (`net`, `tls`, `readline`, `perf_hooks`, `worker_threads`) | 4 (Deferred) | `require()` returns stubs; APIs throw deterministic unsupported errors when called. |
5859
| Unsupported modules (`dgram`, `cluster`, `wasi`, `inspector`, `repl`, `trace_events`, `domain`) | 5 (Unsupported) | `require()` fails immediately with deterministic unsupported-module errors. |
5960

@@ -69,8 +70,25 @@ The [project-matrix test suite](https://github.com/rivet-dev/secure-exec/tree/ma
6970
| [vite](https://npmjs.com/package/vite) | Build Tool | ESM, HMR server, plugin system |
7071
| [astro](https://npmjs.com/package/astro) | Web Framework | Island architecture, SSR, multi-framework |
7172
| [hono](https://npmjs.com/package/hono) | Web Framework | ESM imports, lightweight HTTP |
73+
| [axios](https://npmjs.com/package/axios) | HTTP Client | HTTP client requests via fetch adapter, JSON APIs |
74+
| [node-fetch](https://npmjs.com/package/node-fetch) | HTTP Client | Fetch polyfill using http module, stream piping |
7275
| [dotenv](https://npmjs.com/package/dotenv) | Configuration | Environment variable loading, fs reads |
7376
| [semver](https://npmjs.com/package/semver) | Utility | Version parsing and comparison |
77+
| [ssh2](https://npmjs.com/package/ssh2) | Networking | SSH client/server, crypto, streams, events |
78+
| [ssh2-sftp-client](https://npmjs.com/package/ssh2-sftp-client) | Networking | SFTP client, file transfer APIs over SSH |
79+
| [pg](https://npmjs.com/package/pg) | Database | PostgreSQL client, Pool/Client classes, type parsers |
80+
| [mysql2](https://npmjs.com/package/mysql2) | Database | MySQL client, connection/pool classes, escape/format utilities |
81+
| [ioredis](https://npmjs.com/package/ioredis) | Database | Redis client, Cluster, Command, pipeline/multi transaction APIs |
82+
| [drizzle-orm](https://npmjs.com/package/drizzle-orm) | Database | ORM schema definition, query building, ESM module graph |
83+
| [ws](https://npmjs.com/package/ws) | Networking | WebSocket client/server, HTTP upgrade, events |
84+
| [jsonwebtoken](https://npmjs.com/package/jsonwebtoken) | Crypto | JWT signing (HS256), verification, decode |
85+
| [bcryptjs](https://npmjs.com/package/bcryptjs) | Crypto | Pure JS password hashing and verification |
86+
| [chalk](https://npmjs.com/package/chalk) | Terminal | Terminal string styling, ANSI escape codes |
87+
| [lodash-es](https://npmjs.com/package/lodash-es) | Utility | Large ESM module resolution at scale |
88+
| [pino](https://npmjs.com/package/pino) | Logging | Structured JSON logging, child loggers, serializers |
89+
| [uuid](https://npmjs.com/package/uuid) | Crypto | UUID generation (v4, v5), validation, version detection |
90+
| [yaml](https://npmjs.com/package/yaml) | Utility | YAML parsing, stringifying, document API |
91+
| [zod](https://npmjs.com/package/zod) | Validation | Schema definition, parsing, safe parse, transforms |
7492
| [rivetkit](https://npmjs.com/package/rivetkit) | SDK | Local vendor package resolution |
7593
| crypto (builtin) | Crypto | `crypto.randomBytes`, `randomUUID`, `getRandomValues` |
7694
| fs-metadata-rename | Filesystem | `stat` metadata, `rename` semantics |
@@ -85,6 +103,7 @@ The [project-matrix test suite](https://github.com/rivet-dev/secure-exec/tree/ma
85103
| yarn-berry-layout | Package Manager | Yarn Berry PnP/node_modules layout |
86104
| bun-layout | Package Manager | Bun `node_modules` layout |
87105
| workspace-layout | Package Manager | npm workspace `node_modules` layout |
106+
| sse-streaming | Networking | SSE server, chunked transfer-encoding, streaming reads |
88107
| net-unsupported (fail) | Error Handling | `net.createServer` correctly errors |
89108

90109
To request a new package be added to the test suite, [open an issue](https://github.com/rivet-dev/secure-exec/issues/new?labels=package-request&title=Package+request:+%5Bpackage-name%5D).

packages/kernel/src/kernel.ts

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -427,9 +427,13 @@ class KernelImpl implements Kernel {
427427
// Resolve output callbacks: when a child inherits non-piped stdio from
428428
// a parent, forward output to the parent's DriverProcess callbacks so
429429
// cross-runtime child output reaches the top-level collector.
430+
// When piped, wire a callback that forwards through the pipe/PTY so
431+
// drivers that emit output via callbacks (Node) reach the PTY/pipe.
430432
let stdoutCb: ((data: Uint8Array) => void) | undefined;
431433
let stderrCb: ((data: Uint8Array) => void) | undefined;
432-
if (!stdoutPiped) {
434+
if (stdoutPiped) {
435+
stdoutCb = this.createPipedOutputCallback(table, 1);
436+
} else {
433437
if (options?.onStdout) {
434438
stdoutCb = options.onStdout;
435439
} else if (callerPid !== undefined) {
@@ -440,7 +444,9 @@ class KernelImpl implements Kernel {
440444
}
441445
if (!stdoutCb) stdoutCb = (data) => stdoutBuf.push(data);
442446
}
443-
if (!stderrPiped) {
447+
if (stderrPiped) {
448+
stderrCb = this.createPipedOutputCallback(table, 2);
449+
} else {
444450
if (options?.onStderr) {
445451
stderrCb = options.onStderr;
446452
} else if (callerPid !== undefined) {
@@ -983,6 +989,32 @@ class KernelImpl implements Kernel {
983989
return this.pipeManager.isPipe(entry.description.id) || this.ptyManager.isPty(entry.description.id);
984990
}
985991

992+
/**
993+
* Create a callback that forwards data through a piped stdio FD.
994+
* Needed for drivers (like Node) that emit output via callbacks rather
995+
* than kernel FD writes (like WasmVM does via WASI fd_write).
996+
*/
997+
private createPipedOutputCallback(
998+
table: ProcessFDTable,
999+
fd: number,
1000+
): ((data: Uint8Array) => void) | undefined {
1001+
const entry = table.get(fd);
1002+
if (!entry) return undefined;
1003+
1004+
const descId = entry.description.id;
1005+
if (this.pipeManager.isPipe(descId)) {
1006+
return (data) => {
1007+
try { this.pipeManager.write(descId, data); } catch { /* pipe closed */ }
1008+
};
1009+
}
1010+
if (this.ptyManager.isPty(descId)) {
1011+
return (data) => {
1012+
try { this.ptyManager.write(descId, data); } catch { /* pty closed */ }
1013+
};
1014+
}
1015+
return undefined;
1016+
}
1017+
9861018
/** Clean up all FDs for a process, closing pipe/PTY ends when last reference drops. */
9871019
private cleanupProcessFDs(pid: number): void {
9881020
const table = this.fdTableManager.get(pid);

0 commit comments

Comments
 (0)