@ai-ecoverse/slicc-kernel 1.80.0 → 1.82.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -278,6 +278,7 @@ The module's default export (or its `main` export) is called with a context and
278
278
  - `/dev`, in `open`, `fs` and their `sync` forms, as a WASI or Emscripten program sees it, with the same modes and owners as under `users`: `null`, `zero`, `full`, `random`, `urandom` and `tty` (character devices, `0666`, root's), `stdin`, `stdout` and `stderr` (links to `/proc/self/fd/0`, `1`, `2`), `fd` (a link to `/proc/self/fd`, so `/dev/fd/N` is descriptor N) and the kernel's terminals `/dev/ttyN` and `/dev/pts/N` (with users on, owned by the session's user or the master's opener, `0620`). `/dev/shm` and `/dev/ptmx` aren't there. `stat` through `/dev/stdin`, `/dev/stdout` or `/dev/fd/N` describes the program's own descriptor (the file it is redirected to, a pipe, a terminal), `readlink` of `/dev/fd/N` names it (`pipe:[N]` and `socket:[N]` for a pipe or a socket), and a closed descriptor is `ENOENT`. Opening a device gives a kernel descriptor on it; opening `/dev/std*` or `/dev/fd/N` opens the file again with the flags asked for when the descriptor is on a file (as Linux reopens `/proc/self/fd/N`, so `append` appends and `truncate` truncates), and otherwise duplicates the descriptor; `/dev/tty` is the program's controlling terminal (`ENXIO` without one); and `/dev/ttyN` and `/dev/pts/N` are opened by name through the kernel, so a terminal that isn't the user's is `EACCES` under `enforce`. A file on anything but a regular file reads and writes as a stream (the position is ignored) and only in the directions it was opened for (`EBADF` otherwise), its `size()` is 0 and `truncate` is `EINVAL`. `readFile` reads a device or a descriptor to its end, up to 64 MiB, and fails with `EFBIG` past that (where Node's `readFile` throws `ERR_FS_FILE_TOO_LARGE`), so `readFile('/dev/zero')` fails instead of hanging, while a bounded `read` of it works. Other paths pay only a prefix check: they make the same kernel calls as before. Creating, removing or renaming names in `/dev` goes to the root file system as before;
279
279
  - `signals`: `on(signal, handler)` catches a signal (by number or as `'SIGINT'`), `ignore(signal)` ignores it and `reset(signal)` restores its default action. A call blocked in a read or write is restarted after a handler runs; signals without a handler keep their default actions, so an uncaught `SIGINT` ends the program with 130 and `SIGKILL` always does;
280
280
  - `sync`: `read`, `write`, `close`, `open` (whose files' methods are synchronous too) and `fs`, made synchronously, for code that cannot `await`, such as a compiled program's file system or a `readFileSync`. A synchronous call holds the worker until the kernel answers, so nothing else of the program runs meanwhile, timers and callbacks included; a signal that arrives during a blocked read or write runs its handler, and the call is then restarted. Descriptors are shared with the rest of the context, so a file opened with `sync.open` can be closed with `close`, and the other way around; `sync.run` and `sync.exit` are described next;
281
+ - `sync.cred()` returns the process's credentials, `{ ruid, euid, suid, rgid, egid, sgid, groups }`, and `sync.setcred(change)` changes them, in the shape of the Emscripten call (see [Users](#users)): each field is optional, `-1` keeps an id, and `groups` sets the group list. They are synchronous, so a runtime can build Node's `process.setuid`, `seteuid`, `setgid`, `setegid`, `setgroups` and `initgroups` on them, and follow the same rules as every other program: root may set anything, anyone else only its own real, effective and saved ids (`EPERM` otherwise), and only root may set the groups; a bad value is `EINVAL`. The new ids count at once for the program's own files and for every child it starts, with any `users` mode (with `'off'`, only the ids change);
281
282
  - `sync.run({ argv, argv0, env, cwd, pgid, session, input, stdin, stdout, stderr, maxBuffer, timeout, killSignal })` runs a command to its end and answers `{ pid, status, signal, stdout, stderr, error }`, as Node's `spawnSync` does. `pgid` and `session` work as for `spawn` below; a group that doesn't exist or is another session's answers `error: { code: 'ESRCH' }` or `'EPERM'`, and a bad option throws `EINVAL`. It suits `execSync`, `spawnSync` and `execFileSync`. `argv0` works as for `spawn` below. The kernel does the whole run while the program's worker waits; other processes go on.
282
283
  - Each stdio is `'pipe'` (the default), `'ignore'`, `'inherit'` (the program's own, a terminal included), or one of the program's descriptors by number, which the child gets as it is (two slots on one descriptor share it, so their writes keep their order). `stderr: 'stdout'` sends stderr to whatever stdout is, the stdout capture included, in the order the child writes. With `'pipe'`, stdin gets `input` (bytes, or a string as UTF-8) and then its end, so `cat` stops; `stdout` and `stderr` come back as bytes and are absent for the other choices. Both are drained while the child runs, so a child that writes more than a pipe holds never stalls. As in Node, the call returns once the child has exited and every captured stream has reached its end, so output a background grandchild writes later is still collected; `timeout` bounds that wait too, so, as with Node's `spawnSync`, a run blocks for as long as a background process keeps a captured stream open unless `timeout` is set; pass one for a command that may leave something in the background. Once the outcome is decided (the end of output, an overflow or a timeout), the captured streams are closed, so a writer still left on them gets `EPIPE` (and `SIGPIPE`) instead of running on.
283
284
  - `status` is the exit status, or `null` with `signal` set when a signal ended the child. A non-zero status is not an error. A command that cannot start (it does not exist, or is not executable) answers `error: { code: 'ENOENT' }` (or the errno), `status: null` and `pid: 0` instead of throwing.
@@ -658,6 +659,13 @@ A character device (`0666`, major 10, minor 251), there in every `users` mode, t
658
659
  - **Clients:** a client's (`kernel.connect`) file calls, mounts and unmounts see the paths read-only, as its processes do.
659
660
  - **Not affected:** the page's own writes to OPFS.
660
661
  - **Writers:** `protect: { paths, writers: ['bios-installer'] }` names process tags whose processes may change the paths, such as the embedder's own installs (`kernel.run(['pnpm', 'install'], { tag: 'bios-installer' })`). A tag matches exactly, with no prefix or case folding. A process gets its tag only from the embedder, through `kernel.run`, `kernel.openTerminal` or `kernel.connect({ tag })`, and passes it to its children, forks and `exec`s; a program or a client can't choose one. Writers work on their own view of the root file system, so a file a writer changes is seen by other processes as between two tabs: close-to-open, at their next open after the writer closes it.
662
+ - **Integrity:** `protect: { paths, integrity: { lookup, unknown } }` also checks what a program is made of before it runs. That covers a wasm module, an Emscripten program's glue, a WASI program's imports module, a JS program's module, the `package.json` of each package under a path (the kernel builds its commands, `binfmt` suffixes and drivers only from manifests that pass, so a package whose manifest changed has no commands, 127, and no drivers, `ENODEV`), a script started through its `#!` line or a `binfmt` suffix, a package driver's module and the TLS engine, when the file lies under a path.
663
+ - **The check:** the kernel hashes the exact bytes it is about to compile or hand to a worker (SHA-256, hex), and asks `lookup(path)` (the real path; on the page, it may be async) for the hash it expects. On a mismatch the program doesn't start: `kernel.run` rejects and a spawn fails with `EACCES` ("integrity check failed"; 126 in a shell, not `ENOEXEC`, which would make a shell run the file as a script), a driver fails to mount, TLS fails, and `onDenial` gets `{ op: 'exec-integrity', path, errno: 'EACCES', pid, uid, tag?, expected, actual }`. A `lookup` that throws counts as a mismatch.
664
+ - **No hash known:** when `lookup` has none (`undefined`), `unknown: 'allow'` (the default) runs the file and reports `{ op: 'exec-unverified', errno: 'ENODATA', … }` once per path, while `unknown: 'deny'` refuses it as a mismatch.
665
+ - **The cache:** a result is kept per real path and the version of the file OPFS itself reports (its `lastModified` and `size`), not the kernel's metadata, so a change made around the kernel is checked again at the next start. The compiled module is cached by the same key, so a program that hasn't changed costs nothing more to start.
666
+ - **Where `lookup` gets its hashes:** from a source independent of the installed files, such as the per-file hashes the embedder derives from the certified package tarball when it builds its catalog. Hashes read back from the installed files prove nothing.
667
+ - **Scripts** are checked when they're executed; the interpreter reads the file again afterwards, so a change in between isn't caught ([#454](https://github.com/ai-ecoverse/slicc-kernel/issues/454)).
668
+ - **Not covered:** what a running program reads itself, such as a JS runtime's modules, python's `.py` files and `dlopen`'s side modules ([#454](https://github.com/ai-ecoverse/slicc-kernel/issues/454)). Protected paths still keep those unwritable.
661
669
  - **Where it is a boundary:** it's one in a browser, where programs' own code can't reach OPFS around the kernel (see [JS programs](#js-programs)). Under the Node entry it isn't, until [#407](https://github.com/ai-ecoverse/slicc-kernel/issues/407), and there `kernel.writeFile` under a path fails too, so write those files into `root` before creating the kernel.
662
670
 
663
671
  ### `/proc`
package/dist/index.js CHANGED
@@ -332,7 +332,12 @@ var DEFAULT_USERS = "enforce";
332
332
  // src/fs/protect.ts
333
333
  function protectInit(protection) {
334
334
  const writers = [...writerTags(protection)];
335
- return { paths: [...protection.paths], ...writers.length > 0 ? { writers } : {} };
335
+ const { integrity } = protection;
336
+ return {
337
+ paths: [...protection.paths],
338
+ ...writers.length > 0 ? { writers } : {},
339
+ ...integrity ? { integrity: { unknown: integrityUnknown(integrity) } } : {}
340
+ };
336
341
  }
337
342
  function writerTags(protection) {
338
343
  const writers = protection?.writers ?? [];
@@ -342,6 +347,40 @@ function writerTags(protection) {
342
347
  return new Set(writers);
343
348
  }
344
349
 
350
+ // src/fs/integrity.ts
351
+ function integrityOptions(options) {
352
+ if (options && typeof options.lookup !== "function") {
353
+ throw new TypeError("protect.integrity.lookup must be a function");
354
+ }
355
+ integrityUnknown(options);
356
+ }
357
+ function integrityUnknown(options) {
358
+ const unknown = options?.unknown ?? "allow";
359
+ if (unknown !== "allow" && unknown !== "deny") {
360
+ throw new TypeError("protect.integrity.unknown must be 'allow' or 'deny'");
361
+ }
362
+ return unknown;
363
+ }
364
+
365
+ // src/integrity-relay.ts
366
+ function integrityAnswerer(port, lookup) {
367
+ const ask = async ({ id, path }) => {
368
+ try {
369
+ const hash = lookup ? await lookup(path) : void 0;
370
+ port.postMessage({ integrityReply: { id, ...typeof hash === "string" ? { hash } : {} } });
371
+ } catch (err) {
372
+ const error = err instanceof Error ? err.message : String(err);
373
+ port.postMessage({ integrityReply: { id, error } });
374
+ }
375
+ };
376
+ return (data) => {
377
+ const asked = data?.integrityAsk;
378
+ if (!asked) return false;
379
+ void ask(asked);
380
+ return true;
381
+ };
382
+ }
383
+
345
384
  // src/kernel/net/loopback-names.ts
346
385
  var HOST_LOOPBACK = "host.slicc.internal";
347
386
  var HOST_LOOPBACK_ADDRESS = [10, 0, 2, 2];
@@ -2099,12 +2138,15 @@ function checkOptions(options) {
2099
2138
  if (options.hostname !== void 0 && !isHostname(options.hostname)) {
2100
2139
  throw new Error(`not a host name: ${String(options.hostname)}`);
2101
2140
  }
2141
+ if (options.protect) protectInit(options.protect);
2142
+ integrityOptions(options.protect?.integrity);
2102
2143
  }
2103
2144
  function hookRelays(worker, options) {
2104
2145
  const sealed = sealedAnswerer(worker, options.sealed);
2105
2146
  const clipboard = clipboardAnswerer(worker, options.clipboard);
2147
+ const integrity = integrityAnswerer(worker, options.protect?.integrity?.lookup);
2106
2148
  return {
2107
- handle: (data) => sealed.handle(data) || clipboard.handle(data),
2149
+ handle: (data) => sealed.handle(data) || clipboard.handle(data) || integrity(data),
2108
2150
  close: () => sealed.close()
2109
2151
  };
2110
2152
  }
@@ -2133,7 +2175,6 @@ function traitsUpdater(transport, call) {
2133
2175
  }
2134
2176
  async function createKernel(options = {}) {
2135
2177
  checkOptions(options);
2136
- writerTags(options.protect);
2137
2178
  const url = options.worker ?? new URL("./kernel-worker.js", import.meta.url);
2138
2179
  const worker = new Worker(url, { type: "module", name: "slicc-kernel" });
2139
2180
  const pending = /* @__PURE__ */ new Map();