@ai-ecoverse/slicc-kernel 1.72.0 → 1.73.1

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
@@ -43,7 +43,7 @@ const { status, stdout, stderr } = await kernel.run(['bash', '-c', 'echo hi > he
43
43
  | `sealed` | none | `(req, { signal }) => Promise<object>`: answers requests written to [`/dev/sealed`](#devsealed). `createNodeKernel` takes it too |
44
44
  | `approve` | none | `(req, { signal }) => 'once' \| 'always' \| 'deny'` (or a promise of one): decides a `sudo` request that needs approval (see [Sudo](#sudo)); without it, such a request is denied. `createNodeKernel` takes it too |
45
45
  | `cdp` | none | `({ runtime }) => Promise<CdpConnection>`: a browser-level DevTools connection for programs that drive a browser (see [Browser automation](#browser-automation-cdp)) |
46
- | `protect` | none | `{ paths }`: directories no process may change, root included, in every `users` mode (see [Protected paths](#protected-paths)). `createNodeKernel` takes it too |
46
+ | `protect` | none | `{ paths, writers? }`: directories no process may change, root included, in every `users` mode, except processes with one of the `writers` tags (see [Protected paths](#protected-paths)). `createNodeKernel` takes it too |
47
47
 
48
48
  ### `kernel.run(argv, options?) → Promise<{ status, stdout, stderr }>`
49
49
 
@@ -95,7 +95,7 @@ The user database, `/etc/passwd` and `/etc/group` (see [Users](#users)):
95
95
  | | |
96
96
  | --- | --- |
97
97
  | `mode` | the `users` option the kernel was created with |
98
- | `add({ name, home?, groups?, umask?, homeGroup?, homeMode? })` | adds a user and resolves with `{ name, uid, gid, home, shell, groups, umask? }`. It's idempotent: an existing name resolves with its entry, keeps its ids and home, joins the groups in `groups` it isn't in yet (it never leaves one), remembers `umask`, creates a missing pnpm home or `config.yaml` (an existing pnpm home keeps its mode with `users: 'off'`), and, with owners on, gives the home to the user again. The new user gets the lowest free id from 1000 up, a private group of the same id and name, membership of `users` and of each group in `groups` (such as `sudo`), `home` (default `/home/<name>`, created), its pnpm home `~/.local/share/pnpm` (`0700`, `0750` for a `scoop-` user) and `~/.config/pnpm/config.yaml` with `minimumReleaseAge: 1440` (pnpm skips releases less than a day old), each only if missing, which an `add` of an existing user also does, and `/bin/bash`. `umask` is the umask its processes start with; the kernel keeps it in memory, so pass it again after a reload. With owners on, every `add`, of a new or an existing user, also gives a home under `/home/<dir>` to the user and sets its pnpm home to `0700` (`0750` for a `scoop-` user) again (see [File owners](#file-owners)). `homeGroup` names the group the home belongs to instead of the user's own, without making the user a member (`groups` does that), and its mode is then `2750`. `homeMode` sets the home's mode instead (setgid included, so `0o2750` makes new files in it take the home's group); like the owner, it is set again by every `add` with owners on, on the home itself only. A name must match `[a-z_][a-z0-9_.-]*`; a group that doesn't exist, in `groups` or `homeGroup`, rejects with `ENOENT` |
98
+ | `add({ name, home?, groups?, umask?, homeGroup?, homeMode? })` | adds a user and resolves with `{ name, uid, gid, home, shell, groups, umask? }`. It's idempotent: an existing name resolves with its entry, keeps its ids and home, joins the groups in `groups` it isn't in yet (it never leaves one), remembers `umask`, creates a missing pnpm home or `config.yaml` (an existing pnpm home keeps its mode with `users: 'off'`), and, with owners on, gives the home to the user again. The new user gets the lowest free id from 1000 up, a private group of the same id and name, membership of `users` and of each group in `groups` (such as `sudo`), `home` (default `/home/<name>`, created), its pnpm home `~/.local/share/pnpm` (`0700`, `0750` for a `scoop-` user) and `~/.config/pnpm/config.yaml` with `minimumReleaseAge: 1440` (pnpm skips releases less than a day old), each only if missing (with owners on, the directories it makes on the way, `~/.config` and `~/.local` included, get `0777` less the user's `umask`, the file `0666` less it, and in a home with `homeGroup` they belong to that group and have the setgid bit, as new directories in a setgid directory do), which an `add` of an existing user also does, and `/bin/bash`. `umask` is the umask its processes start with; the kernel keeps it in memory, so pass it again after a reload. With owners on, every `add`, of a new or an existing user, also gives a home under `/home/<dir>` to the user and sets its pnpm home to `0700` (`0750` for a `scoop-` user) again (see [File owners](#file-owners)). `homeGroup` names the group the home belongs to instead of the user's own, without making the user a member (`groups` does that), and its mode is then `2750`. `homeMode` sets the home's mode instead (setgid included, so `0o2750` makes new files in it take the home's group); like the owner, it is set again by every `add` with owners on, on the home itself only. A name must match `[a-z_][a-z0-9_.-]*`; a group that doesn't exist, in `groups` or `homeGroup`, rejects with `ENOENT` |
99
99
  | `remove(name, { keepHome }?)` | removes the user, its private group and its group memberships, and deletes its home unless `keepHome` is set. A home outside `/home/<dir>` is never deleted. Resolves `false` for an unknown name; system accounts (ids below 1000 or above 60000) reject with `EPERM` |
100
100
  | `list()` | every user, as `add` returns them |
101
101
 
@@ -518,7 +518,7 @@ An exec'd program takes over the pid of the process it replaces, as on Linux: `g
518
518
 
519
519
  ### File modes and the umask
520
520
 
521
- Every process has a umask in the kernel, `022` for a process the embedder starts, inherited across fork, spawn and exec and shown in `/proc/<pid>/status` (`Umask:`). The kernel applies it when a program creates a file or directory, whatever its libc does: an Emscripten program's `open` with `O_CREAT`, `mkdir` and `mknod` (so `umask 077; touch f` gives `600` in a child too), and a WASI program's `path_open` with `O_CREAT` and `path_create_directory` (`0666` and `0777` less the umask). An Emscripten program's `umask()` sets and reads the kernel's umask through the glue's `__syscall_umask` import, so `umask 077` in bash shows in `/proc` and reaches every child. A build that resolves `umask` inside its wasm, as wasm-bash 5.3.0-8 to 5.3.0-10 do, keeps a umask of its own that the kernel never sees.
521
+ Every process has a umask in the kernel, `022` for a process the embedder starts, inherited across fork, spawn and exec and shown in `/proc/<pid>/status` (`Umask:`). The kernel applies it when a program creates a file or directory, whatever its libc does: an Emscripten program's `open` with `O_CREAT`, `mkdir` and `mknod` (so `umask 077; touch f` gives `600` in a child too), and a WASI program's `path_open` with `O_CREAT` and `path_create_directory` (`0666` and `0777` less the umask). An Emscripten program's `umask()` sets and reads the kernel's umask through the glue's `__syscall_umask` import, so `umask 077` in bash shows in `/proc` and reaches every child. The kernel finds these imports by the glue's own functions (`___syscall_openat`, `___syscall_mkdirat`, `___syscall_mknodat`, `___syscall_umask`, and `___syscall_faccessat` for `access(2)`), so it also does so for glue whose imports are minified to short names, as wasm-curl's and wasm-clang's are; a glue that has one of those functions but doesn't import it under any name is refused. A build that resolves `umask` inside its wasm, as wasm-bash 5.3.0-8 to 5.3.0-10 do, keeps a umask of its own that the kernel never sees.
522
522
 
523
523
  WASI and WASIX have no call for file modes, so the kernel adds the import module `slicc_fs`, whose functions return a preview1 errno (kernels without them answer `ENOSYS`):
524
524
 
@@ -590,9 +590,10 @@ With `users: 'enforce'` the kernel checks every file call that reaches it agains
590
590
  - **Opening a file:** an open needs `r` to read and `w` to write or truncate, checked once when the file is opened, in every runtime: Emscripten, WASI (a second open of a path the program already has open is checked as well), JS programs' `ctx.open` and `ctx.fs.writeFile`, and the kernel's own opens. A file the open itself creates is not checked (only its directory is), so `open(O_CREAT | O_WRONLY, 0444)` works. An open descriptor keeps the access it was opened with, through a later `chmod`, and so do descriptors a child inherits. Reads and writes without a descriptor (`ctx.fs.readFile`, `ctx.sync.fs.readFile`) are checked each time.
591
591
  - **`access(2)`:** `faccessat` (bash's `[ -r ]`, `test -w`) answers from the same checks, with the real uid and gid, or the effective ones with `AT_EACCESS`, for Emscripten programs, and for WASI programs through `slicc_fs.path_access` (see [File modes and the umask](#file-modes-and-the-umask)).
592
592
  - **Exec:** running a program by path needs `x` on it, and `r` too when it's a script (`#!` or a `binfmt` suffix); a WASI or Emscripten binary needs only `x`. Root needs an `x` bit as well. Commands found by name, and `/usr/bin` and `/bin`, are always executable; a user's own commands in `~/.local/share/pnpm/bin` are reached through the user's home, so another user gets `EACCES` there, as for any file. A refused exec is `EACCES` (126 in bash).
593
- - **The layout:** with `users: 'report'` or `'enforce'`, the kernel gives its own paths an owner and a mode when they have no owner record yet: on a fresh root, and once on a root from before owners. These are `/` `0755`, `/root` `0700`, `/home` `0755`, `/etc` `0755`, `/etc/passwd` and `/etc/group` `0644`, `/usr` and `/usr/local` `0755`, `/node_modules` (or the `modules` option) and the system `PNPM_HOME` `0755`, all root's. A path that has a record keeps it, so an admin's `chmod 711 /home` or `chown cone /node_modules` lasts across restarts; a mounted path is left to its mount, and a path below a symlinked directory is stamped where it really is. Only a root that records owners is stamped: the OPFS root with its sidecar, which the page and the Node entry (in memory) both use; a custom root file system without owner records is left as it is. The directory for files removed while open, `/.slicc-unlinked`, is made root's `0700`. `/tmp` is the exception: it is set to `1777` at every start. Emscripten programs see `/` at `0755`, `/dev` at `0755`, `null`, `zero`, `full`, `random`, `urandom` and `tty` at `0666`, and `/dev/shm` at `1777`. With `users: 'off'` nothing is stamped. Paths the embedder makes (bios's `/opt` and `/var/lib/slicc`) are the embedder's to set. `/etc/sudoers` and `/etc/sudoers.d` are written when missing (see [Sudo](#sudo)).
593
+ - **The layout:** with `users: 'report'` or `'enforce'`, the kernel gives its own paths an owner and a mode when they have no owner record yet: on a fresh root, and once on a root from before owners. These are `/` `0755`, `/root` `0700`, `/home` `0755`, `/etc` `0755`, `/etc/passwd` and `/etc/group` `0644`, `/usr` and `/usr/local` `0755`, `/node_modules` (or the `modules` option) and the system `PNPM_HOME` `0755`, all root's. A path that has a record keeps it, so an admin's `chmod 711 /home` or `chown cone /node_modules` lasts across restarts; a mounted path is left to its mount, and a path below a symlinked directory is stamped where it really is. Only a root that records owners is stamped: the OPFS root with its sidecar, which the page and the Node entry (in memory) both use; a custom root file system without owner records is left as it is. The directory for files removed while open, `/.slicc-unlinked`, is made root's `0700`. `/tmp` is the exception: it is set to `1777` at every start, and the first time owners are on for a root from before owners, what is in it without an owner of its own is removed (as a boot empties `/tmp` on Linux; a file that is open is kept, a symlinked `/tmp` is left alone, and one `console.warn` line says how many entries went), since it would otherwise be root's, and a program that made it for its user, such as pnpm's lock directories, would find it isn't its own. Emscripten programs see `/` at `0755`, `/dev` at `0755`, `null`, `zero`, `full`, `random`, `urandom` and `tty` at `0666`, and `/dev/shm` at `1777`. With `users: 'off'` nothing is stamped. Paths the embedder makes (bios's `/opt` and `/var/lib/slicc`) are the embedder's to set. `/etc/sudoers` and `/etc/sudoers.d` are written when missing (see [Sudo](#sudo)).
594
594
  - **Terminals:** a terminal from `openTerminal` belongs to its session's user, with group `tty` when `/etc/group` has one (the user's own group otherwise), mode `0620`; a pseudo-terminal's slave `/dev/pts/N` belongs to the uid and gid that opened `/dev/ptmx`, mode `0620`. Opening one by name (`/dev/ttyN`, `/dev/pts/N`, from every runtime) needs both `r` and `w`, so only its owner and root open it: the kernel's terminal descriptor always reads and writes, so the group's `w` bit (`write`, `wall` on Linux) does not let a group member open it. `/dev/tty` is the opener's own controlling terminal and is not checked, and descriptors a process inherited (across `sudo` or a `setresuid` too) keep working. Emscripten programs' `stat` of `/dev/ttyN` and `/dev/pts/N` shows the owner and mode. With `users: 'off'` terminals have no owner, as before.
595
595
  - **A client's `fs`:** checked as the client's user; a page's client (root) passes.
596
+ - **Removed files still open:** a process that holds a removed file may look it up (`stat`, `lstat`) under the hidden name the kernel gave it in `/.slicc-unlinked`, which is root's `0700`; every other process is refused there, so the kernel's own bookkeeping of held files raises no denials for their holders.
596
597
 
597
598
  `users: 'enforce'` is not the default yet; it becomes so once the embedders have checked their programs under `'report'`.
598
599
 
@@ -603,7 +604,7 @@ With `users: 'enforce'` the kernel checks every file call that reaches it agains
603
604
  - **`sudo` elevates only under `users: 'enforce'`.** With `'off'` and `'report'`, `sudo command` just runs `command`, as the caller, with its environment. Under `'report'` nothing is refused, so anyone could rewrite the policy or a command; `sudo` grants nothing until the kernel enforces.
604
605
  - **The policy** is `/etc/sudoers` and the files in `/etc/sudoers.d/` (in name order, skipping names that start with `.` or end in `~`), which the kernel writes at start when they're missing, with owners on: `/etc/sudoers` root's `0440` with the one line `%sudo Cmnd *`, and `/etc/sudoers.d` root's `0750`. There is no API: an admin (bios, as root) edits `/etc/sudoers.d/<user>`. A line is `[NOPASSWD] <user|%group|ALL> [(<target>)] Cmnd <argv…|*>`: who (a user name, a group, or anyone), the user it may run as (`root` by default, or `ALL`), and the command, as shell-quoted words whose first is an absolute path, matched exactly against the resolved path and the arguments, or `*` for any command. `#` lines and blank lines are ignored. A line that doesn't parse is skipped, and so is a policy file that isn't root's, is a symlink, or is writable by group or others, or one in a directory that is (each logged once with `console.warn`): what's skipped grants nothing. Groups are the caller's process groups, as `id` shows them.
605
606
  - **The embedders' recipe** puts cones in `sudo` (`groups: ['sudo']`) and scoops not, so a cone may ask for any command and a scoop for none.
606
- - **The command** is found once, on `/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin` for a bare name or against the caller's directory for a path, and its real path (symlinks resolved) is what runs and what the policy sees. That file, and every file that will really run for it (a catalog command's glue, module and imports module, and the `package.json` that defines it, a script and the interpreter its `#!` line or `binfmt` suffix names), must be root's and writable by root only, and so must every directory above each, or `sudo` refuses (`EPERM`), whatever the policy says. Other files a program loads at run time (a package's libraries) are the admin's to keep safe, as on Linux. The caller's directory, which the program passes on, must be a directory the caller may enter, or `sudo` refuses (`EACCES`). The kernel resolves it to its real path once, shows that in the request, and runs the command there; if it no longer resolves the same, or the caller can no longer enter it, when the answer comes or when the command starts, nothing runs.
607
+ - **The command** is found once, on `/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin` for a bare name or against the caller's directory for a path, and its real path (symlinks resolved) is what runs and what the policy sees. That file, and every file that will really run for it (a catalog command's glue, module and imports module, and the `package.json` that defines it, a script and the interpreter its `#!` line or `binfmt` suffix names), must be root's and writable by root only, and so must every directory above each, or `sudo` refuses (`EPERM`), whatever the policy says. A command that wouldn't start as the target user (a `#!` line naming an interpreter that is only the caller's, say) fails with `command not found` before anything is asked. Other files a program loads at run time (a package's libraries) are the admin's to keep safe, as on Linux. The caller's directory, which the program passes on, must be a directory the caller may enter, or `sudo` refuses (`EACCES`). The kernel resolves it to its real path once, shows that in the request, and runs the command there; if it no longer resolves the same, or the caller can no longer enter it, when the answer comes or when the command starts, nothing runs.
607
608
  - **The decision:** a caller whose effective uid is 0 runs anything (the check on the command's path still applies). Otherwise a matching `NOPASSWD` line runs the command at once; any other matching line asks `approve`; nothing matching is refused (`sudo: cone is not allowed to run /usr/bin/ls as root`).
608
609
  - **Approval:** `approve(req, { signal })` gets `{ id, kind: 'sudo', user, uid, target, argv, path, cwd, pid, tag?, tty?, createdAt, expiresAt, always: true }`: the caller's name and real uid, the target user, the argv as typed, the resolved `path`, the caller's directory, pid, tag and terminal, and the times in ms. It answers `'once'` (run it), `'always'` (run it, and append `NOPASSWD <user> (<target>) Cmnd <path> <args…>` to `/etc/sudoers.d/<user>`, root's `0440`, so it's never asked again; if that can't be written, nothing runs) or `'deny'`; a throw, any other answer or no hook at all is `sudo: approval denied`. After 5 minutes the request expires (`signal` aborts with `'expired'`, and `sudo` fails with `sudo: the request expired`); when the requester ends or gets a signal while it waits, `^C` included, `signal` aborts with `'withdrawn'` and `sudo` ends with that signal's status. A user has at most one request waiting; another fails with `sudo: another request is pending`. On the page, `createKernel` relays the request from the kernel worker and back, and the abort too; `terminate()` or a failed worker aborts every request still waiting with `'withdrawn'`.
609
610
  - **Running:** the command runs as the caller's child, with all six ids and the groups of the target user, in the caller's directory, on its terminal, standard input, output and error, and in its process group; `sudo` exits with its status, and passes `SIGTERM` and `SIGHUP` on to it, also one that comes while it starts (the kernel lets it signal the command it started, which its own user may not); `SIGINT` and `SIGQUIT` it leaves to the command, which gets them from the terminal itself. It's found among the system's commands only, never a user's own. Its environment is the kernel's (the embedder's `env` and the proxy settings), the target's `HOME`, `USER`, `LOGNAME`, `SHELL`, `PNPM_HOME` and `NPM_CONFIG_PREFIX`, `PATH` set to the path above, `PWD`, the caller's `TERM` when it is a plain name (letters, digits, `.`, `_`, `+`, `-`), and `SUDO_USER`, `SUDO_UID`, `SUDO_GID` and `SUDO_COMMAND`; nothing else of the caller's comes through.
@@ -632,10 +633,10 @@ A character device (`0666`, major 10, minor 250) through which a program talks t
632
633
  - `/` itself, which holds all of these.
633
634
  Paths in the layout the kernel stamps with root's owner at boot are skipped when protected.
634
635
  - **Symlinks as paths:** a path that is itself a symlink protects the directory it names too, by its real path, once the kernel has prepared.
635
- - **Mounts:** nothing can be mounted at, under or above a path, not even by the embedder (`kernel.mount`, `/etc/fstab`). So a path always lies on the root file system, where that check holds.
636
+ - **Mounts:** nothing can be mounted at, under or above a path, not even by the embedder (`kernel.mount`, `/etc/fstab`), and not by a writer. Above includes `/`: with any path protected, nothing can be mounted on `/`. So a path always lies on the root file system, where that check holds.
636
637
  - **Clients:** a client's (`kernel.connect`) file calls, mounts and unmounts see the paths read-only, as its processes do.
637
638
  - **Not affected:** the page's own writes to OPFS.
638
- - **Writers:** a `writer` that lets chosen processes change the paths needs the kernel-side process tag ([#405](https://github.com/ai-ecoverse/slicc-kernel/pull/405)). Until then, `protect.writer` is refused.
639
+ - **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.
639
640
  - **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.
640
641
 
641
642
  ### `/proc`
package/dist/index.js CHANGED
@@ -288,7 +288,17 @@ async function loopbackFetch(dial, input, options) {
288
288
  }
289
289
 
290
290
  // src/fs/protect.ts
291
- var PROTECT_WRITER = "slicc-kernel: protect.writer needs the process tag (#405), which this kernel does not have yet";
291
+ function protectInit(protection) {
292
+ const writers = [...writerTags(protection)];
293
+ return { paths: [...protection.paths], ...writers.length > 0 ? { writers } : {} };
294
+ }
295
+ function writerTags(protection) {
296
+ const writers = protection?.writers ?? [];
297
+ if (!Array.isArray(writers) || writers.some((tag) => typeof tag !== "string" || tag === "")) {
298
+ throw new TypeError("protect.writers must be a list of process tags");
299
+ }
300
+ return new Set(writers);
301
+ }
292
302
 
293
303
  // src/kernel/net/loopback-names.ts
294
304
  var HOST_LOOPBACK = "host.slicc.internal";
@@ -1845,7 +1855,7 @@ function initCall(options, transport) {
1845
1855
  ...options.onDenial ? { denials: true } : {},
1846
1856
  ...options.sealed ? { sealed: true } : {},
1847
1857
  ...options.approve ? { approve: true } : {},
1848
- ...options.protect ? { protect: { paths: [...options.protect.paths] } } : {},
1858
+ ...options.protect ? { protect: protectInit(options.protect) } : {},
1849
1859
  ...options.processMounts !== void 0 ? {
1850
1860
  processMounts: typeof options.processMounts === "function" ? "ask" : options.processMounts
1851
1861
  } : {}
@@ -1962,7 +1972,7 @@ function checkOptions(options) {
1962
1972
  }
1963
1973
  async function createKernel(options = {}) {
1964
1974
  checkOptions(options);
1965
- if (options.protect?.writer) throw new Error(PROTECT_WRITER);
1975
+ writerTags(options.protect);
1966
1976
  const url = options.worker ?? new URL("./kernel-worker.js", import.meta.url);
1967
1977
  const worker = new Worker(url, { type: "module", name: "slicc-kernel" });
1968
1978
  const pending = /* @__PURE__ */ new Map();