@ai-ecoverse/slicc-kernel 1.81.0 → 1.83.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 +23 -1
- package/dist/index.js +44 -3
- package/dist/index.js.map +3 -3
- package/dist/kernel-worker.js +1203 -221
- package/dist/kernel-worker.js.map +4 -4
- package/dist/node-process-worker.js.map +2 -2
- package/dist/node.js +1174 -225
- package/dist/node.js.map +4 -4
- package/dist/process-worker.js.map +2 -2
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -624,10 +624,25 @@ With `users: 'enforce'` the kernel checks every file call that reaches it agains
|
|
|
624
624
|
A character device (`0666`, major 10, minor 250) through which a program talks to the embedder's `sealed` hook (`createKernel({ sealed })`), the secrets vault on the page. Emscripten and WASI programs can open it (Emscripten programs see it in `/dev`); JS programs can't yet.
|
|
625
625
|
- **State per open:** each `open()` is independent; `dup` and fork share one, as on Linux.
|
|
626
626
|
- **One write is one request:** a single UTF-8 JSON object of at most 64 KiB with `v` 1 and a known `op` (`seal`, `placeholder`, `list`, `test`, `request`, `status`, `cancel`, `materialize`). The write takes all the bytes or fails: `EMSGSIZE` over 64 KiB, `EINVAL` for anything else that isn't such an object. These are the only errnos; every other outcome is a JSON reply. The kernel zeroes the bytes it was given once it has parsed them.
|
|
627
|
-
- **The stamp:** the hook gets the request with `caller: { pid, ppid, uid, gid,
|
|
627
|
+
- **The stamp:** the hook gets the request with `caller: { pid, ppid, uid, gid, user?, groups, users, tag? }`, from the process that writes (at the write; not the one that opened it): `uid` and `gid` the effective ids, `user` the `/etc/passwd` name of `uid` read at that moment (left out when there is none), `groups` the process's supplementary groups (only root can change them), `users` the kernel's `users` mode, since a uid means more under `'enforce'`, and `tag` the process's tag (see `kernel.run`) when it has one. A `caller` in the request is replaced.
|
|
628
628
|
- **Reading:** the reply comes from the same descriptor, as JSON (`{ v: 1, … }`). A read waits for it, drains it, then returns end-of-file; with no request outstanding it returns end-of-file at once. A signal ends the wait with `EINTR` and the request stays outstanding, `O_NONBLOCK` gives `EAGAIN`, and `poll`/`select` report the descriptor readable once the reply is there. A new write drops an unread reply.
|
|
629
629
|
- **The hook:** `signal` aborts with `'timeout'` after 10 s (the reply is then `{ v: 1, error: { code: 'ETIMEDOUT' } }`), `'replaced'` when a new write on the descriptor comes first, and `'withdrawn'` when the writer exits or the descriptor is closed. A throw or a reply that isn't an object becomes an `EIO` error reply, with the first line of the message; no hook is `ENOTCONN`. On the page, `createKernel` relays the request from the kernel worker and back, and the abort too.
|
|
630
630
|
|
|
631
|
+
**Exec tickets.** A `materialize` request needs `argv` (an array of strings), `path` and `cwd` (strings), and `names` (an array of strings) or `shellGrants: true`; anything else is `EINVAL` at the write. When the hook's reply carries a `ticket` (32 lowercase hex digits, no `error`), the kernel books it for the process that wrote the request: the pid, the `path`, the sha256 of the JSON of `argv`, and the `cwd`, for 30 s, at most 256 at a time. A spawn or exec whose environment has `SLICC_SEALED_TICKET` (from WASIX `proc_spawn`/`proc_exec`, an Emscripten `exec`, a JS program's `spawn` or `sync.run`, or `sudo`) redeems it, and the kernel takes the variable out of the child's environment either way. It also takes it out of what `run`, `spawn` and `openTerminal` are given. The ticket is used up by any attempt, and the spawn fails with `EACCES` (status 126 in bash, with no message beyond the errno) unless all of these hold:
|
|
632
|
+
- the ticket is booked and under 30 s old;
|
|
633
|
+
- the process spawning or exec'ing is the one that sent the `materialize`;
|
|
634
|
+
- the program's file is absolute and, with its symlinks resolved, the same file as `path` resolved the same way when the ticket was booked (so fnox sends the path it would exec, from a `PATH` lookup or not; bash hands over the file it found, with `argv[0]` as typed);
|
|
635
|
+
- the sha256 of the `argv` the spawn or exec asked for, with the `argv[0]` the child really gets (a spawn's `argv0`, unless the command's manifest fixes its own), is the booked one; arguments a command's manifest adds in front (`args`) are the command's, and aren't part of it;
|
|
636
|
+
- the child's directory, symlinks resolved, is `cwd`, and the child starts in that resolved directory.
|
|
637
|
+
|
|
638
|
+
The kernel then asks the hook `{ v: 1, op: 'materializeConsume', ticket, stamp, path, resolved, argv, cwd, interpreters, trusted }` (slicc-vault#10):
|
|
639
|
+
- `stamp` is the child's caller stamp, as above (`pid` the child's; `ppid` the process that spawned or exec'd it, which is the one that sent the `materialize`, so for an exec both are that process's pid), also given as `caller`, as on every request;
|
|
640
|
+
- `path` and `cwd` are the ones the `materialize` named, and `resolved` the file that runs, symlinks resolved;
|
|
641
|
+
- `interpreters` is the chain of `#!` and `binfmt` interpreters the kernel will really run, outermost first, each absolute with its symlinks resolved, and empty for a program of the catalog (Emscripten, WASI or JS) and the `/usr/bin/<name>` entries for one. A plain `#!/usr/bin/env X` (or `env -S X …`) is reported as `X`: a command of the catalog as `/usr/bin/X`, a script command by its file. `env` with any other option or a `NAME=value` assignment is run and reported as `/usr/bin/env` itself;
|
|
642
|
+
- `trusted` is true when the program and every interpreter are regular files owned by root and not writable by group or others, under directories that are all the same, up to `/` (for a command of the catalog, its package files). It's false unless `users` is `'enforce'`: under `'off'` files have no owners, and under `'report'` owners don't stop a write. The reply must be `{ values, placeholders }` (string maps) within 10 s; anything else is `EACCES`. Each variable of the child's environment whose value is exactly `placeholders[NAME]` gets `values[NAME]`; nothing else changes. The kernel keeps no copy and logs nothing of either. Like any environment, the values are inherited by whatever the child itself spawns or execs, as on Linux. A ticket isn't carried across `sudo`, which builds its own environment: a `sudo` run with `SLICC_SEALED_TICKET` set fails with `EACCES`, and burns the ticket.
|
|
643
|
+
|
|
644
|
+
**The child's output is scrubbed (S2).** The kernel compiles a [slicc-scrub](https://github.com/ai-ecoverse/slicc-scrub) scrubber (1.0.0, bundled; the published package has no runtime dependency) over exactly the values it swapped in, and gives the child new open file descriptions for fd 1 and fd 2, for every other descriptor it holds on the same descriptions (a `dup` of them), and for every descriptor on a terminal (fd 0 of a terminal session is one), that pass every write through it into the originals (one wrapper per original description, so `2>&1` keeps its order). A value appears as its placeholder, and a fragment or encoded form of it (JSON, percent, HTML, base64, hex, wrapped) as `[redacted:NAME]`. Bytes go through at once unless they may still be the start of a match; such a tail is let out after 50 ms without a write when it's shorter than 16 bytes, and otherwise at the next write or at close. The child's descendants inherit the scrubbed descriptions through fork, spawn, exec and `dup`, and a descendant that opens its controlling terminal (`/dev/tty`, or a terminal by name) gets one scrubbed too, as does a pseudo-terminal slave (`/dev/pts/N`) it opens. Files, sockets and pipes the child or a descendant opens itself are not scrubbed, so `printf %s "$T" > f` writes the value. A scrubbed fd 1 or 2 is a stream: it doesn't seek. A descendant that redeems a ticket of its own adds its values: its output is scrubbed for both sets. A value under 8 bytes is swapped but can't be scrubbed. The scrubber goes when the last process and description holding it do.
|
|
645
|
+
|
|
631
646
|
### `/dev/clipboard`
|
|
632
647
|
|
|
633
648
|
A character device (`0666`, major 10, minor 251), there in every `users` mode, through which programs read and write the embedder's clipboard (`createKernel({ clipboard: { read, write } })`; on the page, bios answers with `navigator.clipboard`). Emscripten programs (`cat /dev/clipboard`, `cat > /dev/clipboard`, see it in `/dev`), WASI programs (`std::fs`) and JS programs (`ctx.fs.readFile`, `ctx.fs.writeFile`, `ctx.open`) all open it.
|
|
@@ -659,6 +674,13 @@ A character device (`0666`, major 10, minor 251), there in every `users` mode, t
|
|
|
659
674
|
- **Clients:** a client's (`kernel.connect`) file calls, mounts and unmounts see the paths read-only, as its processes do.
|
|
660
675
|
- **Not affected:** the page's own writes to OPFS.
|
|
661
676
|
- **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.
|
|
677
|
+
- **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.
|
|
678
|
+
- **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.
|
|
679
|
+
- **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.
|
|
680
|
+
- **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.
|
|
681
|
+
- **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.
|
|
682
|
+
- **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)).
|
|
683
|
+
- **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.
|
|
662
684
|
- **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.
|
|
663
685
|
|
|
664
686
|
### `/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
|
-
|
|
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();
|