@ai-ecoverse/slicc-kernel 1.176.1 → 1.177.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
@@ -45,7 +45,7 @@ TypeScript declarations ship in `dist/types/`, and each entry (`.`, `./node`, `.
45
45
  | `sealed` | none | `(req, { signal }) => Promise<object>`, or `{ request, fetch? }` with that function as `request`: `request` answers requests written to [`/dev/sealed`](#devsealed), and `fetch` fills requests that carry a sealed placeholder (see **The fetch hook** under [`/dev/sealed`](#devsealed)). `createNodeKernel` takes it too. An exported `SEALED_FETCH_VERSION`, the number `1`, says that this kernel takes the object |
46
46
  | `clipboard` | none | `{ read(stamp) => Promise<string>, write(text, stamp) => Promise<void> }`: the clipboard behind [`/dev/clipboard`](#devclipboard). `createNodeKernel` takes it too |
47
47
  | `requests` | none | `{ kinds, handle(request) }`: the page's answers to [`/dev/embedder`](#devembedder) for the processes it starts. `createNodeKernel` takes it too |
48
- | `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 |
48
+ | `approve` | none | `(req, { signal }) => 'once' \| 'always' \| 'deny'` (or a promise of one), or `{ answer: 'once' \| 'always', timestamp: true }` when a person approved: decides a `sudo` request that needs approval (see [Sudo](#sudo) and [Sudo timestamps](#sudo-timestamps)); without it, such a request is denied. `createNodeKernel` takes it too |
49
49
  | `cdp` | none | `({ runtime }) => Promise<CdpConnection>`: a browser-level DevTools connection for programs that drive a browser (see [Browser automation](#browser-automation-cdp)) |
50
50
  | `facades` | none | `{ link: { port, hook? } }`: kernel-only loopback ports whose connections a hook on the page serves, with the connecting program's identity (see [Page facades](#page-facades)) |
51
51
  | `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 |
@@ -480,7 +480,7 @@ The module's default export (or its `main` export) is called with a context and
480
480
  - **Scope:** preview1 only, as Node's `node:wasi`. There are no WASIX calls, and no `thread-spawn` from the guest: emnapi starts its threads as JS workers, which are `thread`s here.
481
481
  - `spawn({ argv, argv0, env, cwd, pgid, session, parentDeathSignal, stdin, stdout, stderr, fds })` starts a command (found as `kernel.run` finds it) as a child process and resolves with `{ pid, stdin, stdout, stderr, wait(), kill(signal) }`. Each stdio is `'inherit'` (the default: the program's own), `'pipe'` (a web stream on a kernel pipe), `'null'`, or one of the program's descriptors. `env` is the child's whole environment (the program's by default), and `cwd` defaults to the program's. The command run is `argv[0]`; `argv0`, when given, is the `argv[0]` the child sees instead, as given (no basename; an empty string too), as with Node's `options.argv0`: `spawn({ argv: ['/bin/sh', '-c', 'echo $0'], argv0: '/bin/sh' })` prints `/bin/sh`, where without it the child sees the command's own name (`sh`). A command whose package manifest sets its `argv0` (a multi-call program's applets, such as coreutils') keeps that one. An `argv0` that isn't a string is `EINVAL`. The child is in the program's process group and session, as after `fork` (Node's `detached: false`), so the terminal's `^C` and `^Z` reach it along with the program. `pgid: 0` makes it lead a new group in the program's session, set before it runs, so a `kill(-pid)` right after the call reaches it and everything it starts (`npx`, then `node`); on the program's terminal that group is in the background, so a read from the terminal stops it with `SIGTTIN` (`EIO` when it ignores `SIGTTIN`), as on Linux. `pgid: N` joins group N, which must be in the program's session: `EPERM` for another session's group, `ESRCH` when there is none. `session: true` makes it lead a new session and a new group (`setsid`, what Node's `detached: true` does on POSIX): its pid is its `pgid` and its `sid`, it has no controlling terminal (`/dev/tty` is `ENXIO`) and the terminal's keys don't signal it. `session` together with `pgid`, or a `pgid` that isn't a non-negative integer, is `EINVAL`. A command that does not exist rejects with `ENOENT`. `parentDeathSignal` (a number or `'SIGTERM'`, as Linux's `PR_SET_PDEATHSIG`) is sent to the child when the program ends, killed or not: to the child's whole group when it leads one (`pgid: 0` or `session: true`), else to the child alone. It's sent with the program's credentials, so a child the program may no longer signal (one that gained privilege) doesn't get it. If the program has already ended by the time the spawn completes, it's sent at once. A child of its own (not a group leader) that has been waited for gets nothing, and `sync.run` has no such option. A bad signal is `EINVAL`;
482
482
  - `wait(pid, { untraced })` resolves with `{ status }` once a child ends, or `{ status: 128 + signal, signal }` when a signal ended it, without blocking the program; with `untraced: true` it also resolves when the child stops, with `{ status: 128 + signal, signal, stopped: true }`, as `waitpid` with `WUNTRACED` (a child's `wait({ untraced })` is the same), and options that aren't an object are `EINVAL`; `kill(pid, signal)` signals any process, by number or as `'SIGTERM'` (the default);
483
- - `sudo(args)`, what the [`sudo`](#sudo) command calls: `args` are its arguments after `sudo`, and it resolves with `{ pid }`, the command the kernel started as the program's child, to `wait` for; `{ run }`, an argv to run as the caller (with `users: 'off'`); `{ list }`, the text of `sudo -l`; or `{ error, errno }`, the message to print. A policy line or file the kernel skipped is reported on the program's standard error, by the kernel, before the call resolves;
483
+ - `sudo(args)`, what the [`sudo`](#sudo) command calls: `args` are its arguments after `sudo`, and it resolves with `{ pid }`, the command the kernel started as the program's child, to `wait` for; `{ run }`, an argv to run as the caller (with `users: 'off'`); `{ list }`, the text of `sudo -l`; `{ done: true }`, for `sudo -k`, `-K` and `-v`, which run nothing; or `{ error, errno }`, the message to print. A policy line or file the kernel skipped is reported on the program's standard error, by the kernel, before the call resolves;
484
484
  - `visudo({ files? })`, what the [`visudo -c`](#sudo) command calls: `files` is `[{ name, text }]`, the files the program read, checked as policy text, or left out to check the policy itself (an effective uid of 0 needed under `users: 'enforce'`). It resolves with `{ lines, ok }`: `lines` is `[fd, text]` pairs to print in order (`1` for `parsed OK`, `2` for a problem), and `ok` is whether nothing was wrong. A malformed call (at most 64 files of 1 MiB each) is `EINVAL`;
485
485
  - `crontab({ action, user?, text? })`, what the [`crontab`](#cron) command calls: `action` is `'list'`, `'remove'` or `'install'` (with the table as `text`), on the caller's own table or, for an effective uid of 0, on `user`'s. It resolves with `{ text }` (the table for `list`, `''` otherwise) or `{ error }`, the message to print. A malformed call is `EINVAL`;
486
486
  - `net`: `listen({ port, host, backlog })` and `connect({ host, port, signal })` (`host` defaults to `127.0.0.1`) on the kernel's loopback, the same sockets WASIX programs use, so a JS server is reached by other processes, by the page as `<port>.kernel.localhost` (`loopbackFetch`, `dial`) and by attached clients. A listener has its `port` (as bound, so port `0` picks a free one), `accept({ signal })`, `close()` and `for await` over its connections. A connection has `local` and `remote` addresses, `readable` and `writable` web streams (closing the writable shuts down the sending side, so the peer reads the end), `close()`, and `reset()`, which closes it with an RST as Node's `resetAndDestroy()` does ([#1530](https://github.com/ai-ecoverse/slicc-kernel/issues/1530)): on the kernel's loopback, the peer first reads the bytes it has already received, as on Linux and BSD ([#1577](https://github.com/ai-ecoverse/slicc-kernel/issues/1577)), and then its reads fail with `ECONNRESET` instead of seeing the end; its writes fail with `ECONNRESET` at once. A TLS connection (`secure()`, a `tls` connect on the loopback) resets the same way. On an uplink connection `reset()` closes at once with a FIN, as the transport can't send an RST: bytes still buffered for the peer are discarded instead of sent. The loopback is IPv4 (`127.0.0.1` by default, or `0.0.0.0`), so an IPv6 host such as `::1` fails with `EADDRNOTAVAIL` for both `listen` and `connect`, and binding and connecting fail as they would for a WASIX program (`EADDRINUSE`, `ECONNREFUSED`, `EADDRNOTAVAIL`). `sync.net.listen` takes the same options and gives the same listener, bound and listening when it returns, so its `port` is known at once, as Node's `listen()` needs ([#1398](https://github.com/ai-ecoverse/slicc-kernel/issues/1398)); it needs the sync lane. A blocked accept, read or write waits without holding anything in the kernel, so aborting it or closing the socket leaves nothing behind. A write to a connection the peer closed fails with `EPIPE` and no `SIGPIPE`;
@@ -745,7 +745,7 @@ In the browser, the page serves the uplink to the kernel worker over the same po
745
745
  - `reset(code)` (default `ECONNRESET`) breaks the connection both ways: writes the peer has not read fail, the program's next read fails with `ECONNRESET`, and its writes with `EPIPE`.
746
746
  - `closed` resolves when the kernel closes the connection.
747
747
 
748
- `fakeCronClock(start?, zone?)` is a clock for the kernel's cron to run on in tests, so a test moves time instead of waiting for it. It starts at `start` (default 2026-01-01T00:00Z). `zone` sets the [time zone](#time-zone) of a kernel started on it that names no `timeZone` of its own: UTC by default or with `true`, an IANA name such as `'Europe/Berlin'`, or the browser's with `false`. Its `timeZone` is that name (none for `false`) and `utc` is whether it is UTC. `now()` is the fake time. `sleep(until, signal)` resolves when `advance(ms)` moves time to `until` or past it, or when `signal` aborts. `set(ms)` sets the wall clock, forward or back, and wakes every sleeper, as a scheduler's next periodic wake would after a real clock change. `sleepers` counts the sleeps still waiting. `flush()` resolves once every sleeper that a time move woke has gone back to sleep for a later time, which is the point where a scheduler has done what was due. It never resolves while nothing sleeps.
748
+ `fakeCronClock(start?, zone?)` is a clock for the kernel's cron to run on in tests (and for [sudo timestamps](#sudo-timestamps), through `createNodeKernel({ sudo: { clock } })`), so a test moves time instead of waiting for it. It starts at `start` (default 2026-01-01T00:00Z). `zone` sets the [time zone](#time-zone) of a kernel started on it that names no `timeZone` of its own: UTC by default or with `true`, an IANA name such as `'Europe/Berlin'`, or the browser's with `false`. Its `timeZone` is that name (none for `false`) and `utc` is whether it is UTC. `now()` is the fake time. `sleep(until, signal)` resolves when `advance(ms)` moves time to `until` or past it, or when `signal` aborts. `set(ms)` sets the wall clock, forward or back, and wakes every sleeper, as a scheduler's next periodic wake would after a real clock change. `sleepers` counts the sleeps still waiting. `flush()` resolves once every sleeper that a time move woke has gone back to sleep for a later time, which is the point where a scheduler has done what was due. It never resolves while nothing sleeps.
749
749
 
750
750
  `setTestSyscallHook(hook)` is **test-only**: while set, every process kernel call (the `WasmSyscall` requests: process, fd, socket, signal and job calls; not the path-only file system requests such as `read`, `write` or `rename`) of a process in this Node process first awaits `hook({ pid, op })`. `pid` is the kernel's own pid of the process, which for an exec'd image is not the pid the program sees (`getpid()`, `ps` show the pid it took over). A test can then act at an exact point (write a ^C when a shell forks) or hold a call (delay a child's `proc-setpgid`). `setTestSyscallHook(undefined)` clears it. Unset, which is the default, it does nothing.
751
751
 
@@ -1066,7 +1066,7 @@ With `users: 'enforce'`, the default, or `'report'`, files have owners, and [Per
1066
1066
  - **Homes:** every `kernel.users.add` gives the user's home (only a real directory at `/home/<dir>`, not a symlink or a path through one) and everything in it without an owner of its own to the user, and sets the home's mode: `0700`, `0750` for a `scoop-` user, `2750` with `homeGroup`, and its `~/.local/share/pnpm`'s to `0700` (`0750` for a `scoop-` user). The embedder calls `add` for each user at boot, as it already does, so homes made before owners were on become the user's. That also means a user's own `chmod` or `chgrp` of its home lasts only until the next `add`, and `add` must get the same `homeGroup` every time.
1067
1067
  - **`chown`, `lchown`, `fchown`:** Emscripten programs, and WASI programs through `slicc_fs` (see [File modes and the umask](#file-modes-and-the-umask)), change owners through the kernel, by the Linux rules: root may set any owner and group; a file's owner may set its group to one it belongs to, or leave it as it is; anything else fails with `EPERM`. `-1` keeps an id. A `chown` that sets an owner or group of a file clears its setuid bit, and its setgid bit if the file is group-executable, whoever calls it, root included, as on Linux. With `users: 'off'`, `chown` succeeds and changes nothing, as before.
1068
1068
  - **What programs see:** `stat` shows the owners to Emscripten programs, whatever their libc reports for their own ids, so a program built before H1 sees real owners too, and its owner checks can fail. WASI programs read them with `slicc_fs.fd_owner` and `path_owner`. The commands in `/usr/bin` and `/bin` are root's, and a user's own commands in `~/.local/share/pnpm/bin` belong to the owner of the closest real directory above them, normally the user.
1069
- - **Mounts:** the options `uid=` and `gid=` (decimal ids) give a mount its owner; without them it belongs to whoever mounted it: root for `kernel.mount`, `/etc/fstab` and a client, the process's effective ids for `mount(2)`, which then shows them in its options (a process that isn't root gets its own ids whatever `uid=` and `gid=` it passes, so it can't make a mount look root's). A mount whose driver keeps owners (`tmpfs`) starts with its root owned so, and its files get owners as on the root file system, `chown` included. On `hostfs`, `fsa` and any driver without `owners`, every file shows as the mount's owner and `chown` fails with `EPERM`, root's too, as on a FAT drive. An embedder that mounts a folder for a user (bios's linked and picked folders) passes that user's `uid` and `gid`, or the user's programs see root's files there, and git calls the repository's ownership dubious.
1069
+ - **Mounts:** the options `uid=` and `gid=` (decimal ids) give a mount its owner; without them it belongs to whoever mounted it: root for `kernel.mount`, `/etc/fstab` and a client, the process's effective ids for `mount(2)`, which then shows them in its options (a process that isn't root gets its own ids whatever `uid=` and `gid=` it passes, so it can't make a mount look root's). A mount whose driver keeps owners (`tmpfs`) starts with its root owned so, and its files get owners as on the root file system, `chown` included. On `hostfs`, `fsa` and any driver without `owners`, every file shows as the mount's owner and `chown` fails with `EPERM`, root's too, as on a FAT drive. An embedder that mounts a folder for a user (bios's linked and picked folders) passes that user's `uid` and `gid`, or the user's programs see root's files there, and git calls the repository's ownership dubious. Those mounts also take the FAT-style options `umask=`, `fmask=` and `dmask=` (octal, as on Linux `vfat`): when any is set, the kernel synthesises the permission bits that `stat` and DAC see from `0666` for files and `0777` for directories (and symlinks), less the mask (`fmask` for files, `dmask` for directories, and `umask` as the default for both). Without them, files stay `0644` and directories `0755` as before, so only the mount's `uid` may write. `umask=000` (or `fmask=000,dmask=000`) makes every file and directory world-writable under DAC, which is how a personal cone (another uid) can write a folder mounted as the template `cone` user. When a mask is set on a mount without a `chmod` capability (`fsa`), `chmod` is a no-op and is not owner-only, so a create that then sets the mode from another uid still succeeds. Without a mask, and on a mount that keeps real modes through `chmod` (`hostfs`), `chmod` still requires the mount's `uid` (or root). Drivers with `owners` (`tmpfs`) ignore the mask options: their modes stay real, and chmod still requires the owner.
1070
1070
 
1071
1071
  ### Permissions
1072
1072
 
@@ -1089,22 +1089,43 @@ With `users: 'enforce'` the kernel checks every file call that reaches it agains
1089
1089
 
1090
1090
  ### Sudo
1091
1091
 
1092
- `sudo` is a command of the kernel itself, in `/usr/bin` (a package's `sudo` never wins over it): `sudo [-n] [-u user] [--] command [args…]` runs `command` as `user` (root by default), and `sudo -l` lists what the caller may run (`visudo -c` checks the policy, below). `-n` never asks: a command that needs approval fails with `sudo: a password is required`. The decision, the credentials and the environment are all made in the kernel; the command only passes its arguments on (`ctx.sudo`, see [JS programs](#js-programs)), so no program can grant itself more by calling the kernel some other way.
1092
+ `sudo` is a command of the kernel itself, in `/usr/bin` (a package's `sudo` never wins over it): `sudo [-kn] [-u user] [--] command [args…]` runs `command` as `user` (root by default), and `sudo -l` lists what the caller may run (`visudo -c` checks the policy, below). `-n` never asks: a command that needs approval fails with `sudo: a password is required`, unless a [timestamp](#sudo-timestamps) lets it run. `sudo -v`, `sudo -k` and `sudo -K` start, end and remove timestamps, as in sudo(8) (below). Any other combination, a command after `-v`, `-l` or `-K`, or no command and no mode, prints the usage. The decision, the credentials and the environment are all made in the kernel; the command only passes its arguments on (`ctx.sudo`, see [JS programs](#js-programs)), so no program can grant itself more by calling the kernel some other way.
1093
1093
 
1094
1094
  - **`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.
1095
1095
  - **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>)] [Cwd <dir>] [Stdin none] Cmnd <argv…|*>`: who (a user name, a group, or anyone), the user it may run as (`root` by default, or `ALL`), the directory it may run in, whether it needs a standard input that carries nothing the approver didn't see, and the command, or `*` for any command. The command is shell-quoted words whose first is an absolute path, matched exactly against the resolved path (a `*` in it is a literal `*`). The arguments follow sudoers(5): **none written** (`Cmnd /usr/bin/mcpcat`) allows the program with any arguments, none included; **`""` as the only argument** (`Cmnd /usr/bin/mcpcat ""`) allows it with no arguments (`''` is one empty argument); **otherwise** each word matches one argument, exactly, except that an unquoted `*` is a wildcard. A word that is `*` alone matches any number of arguments, none included (`Cmnd /usr/bin/mcpcat list *`), and a `*` inside a word matches any text within that one argument (`--log=*.txt`). A quoted or escaped `*` (`\*`, `'*'`, `"*"`) is a literal `*`, and `*` is the only wildcard: `?` and `[` are literal. **Unlike sudo**, which matches the arguments joined by spaces as one string, so that `/bin/cat /var/log/*` also allows `cat /var/log/x /etc/shadow`, a `*` here never reaches past the argument it is in: that line allows one argument under `/var/log/`, while `/bin/cat *` allows any arguments. `sudo -l` shows the command as it reads, `*` unquoted and a literal `*` as `\*`. **Upgrading:** before this, a command with no arguments allowed only a bare call and `*` was literal, so a hand-written line without arguments now allows any arguments, and an unquoted `*` now matches. Narrow such a line with `""` or the exact arguments if a bare call is what it meant. A hand-written `""` used to be one empty argument and is now no arguments; write `''` for the empty argument. A line in the exact format an `'always'` answer wrote for a bare call in a kernel from before, `NOPASSWD <user> (<target>) [Cwd <dir>] Cmnd <path>` in `/etc/sudoers.d/<user>`, character for character (trailing whitespace on the line ignored), is always read as if it ended in `""`, so it keeps allowing only that bare call, and `sudo -l` shows it with the `""`. That holds for a line an older kernel appends later, on the same root, too. A hand-written line that is character for character in that format is read the same way, so add arguments or `*`, or drop the `(<target>)`, to make it wider. Recorded lines with arguments are unchanged, since the kernel quoted each `*` it wrote. `Cwd` takes one shell-quoted absolute path, and the line then matches only when the caller's directory, resolved to its real path (the one the request shows and the command runs in), is that path, so write it without symlinks: one that has a symlink in it never matches. A line without `Cwd` matches in any directory. `sudo -l` shows the clause as `CWD=<dir>`. `Stdin none` makes the line match only when the caller's standard input is a terminal, `/dev/null` or closed, so a rule for `tee /etc/hosts` typed at a terminal never covers `echo … | sudo tee /etc/hosts` or `< file`. A terminal is any terminal, which a program can also drive through a pseudo-terminal of its own (`/dev/ptmx`, as `script` and `expect` do), so `Stdin none` keeps piped, redirected and file input out, not every input a person didn't type; `none` is the only value, and any other makes the line not parse. A line without `Stdin` matches whatever the input, as before. `sudo -l` shows it as `STDIN=none`, after `CWD=`. A line in the bare-call format below with `Stdin none ` before `Cmnd` is read as ending in `""` too. `#` lines and blank lines are ignored. Groups are the caller's process groups, as `id` shows them.
1096
- - **The sudoers(5) form:** a line can also be written the way sudoers(5) writes one, for the common subset: `<user|%group|ALL> ALL=[(<target>[,<target>…][:<group>…])] [NOPASSWD:|PASSWD:] <command…|ALL>`, with optional spaces around `=`. The host must be `ALL`. The run-as list is user names or `ALL`, root when it is left out, and a line with several (`cone ALL=(cone-32,cone-33) …`) is one rule per user, as if written once per user in the kernel form; a `:<group>` part is accepted and ignored: the kernel's `sudo` has no `-g`, and the rule runs with the target's own groups, so `(root:restricted)` can differ from sudo, which would run it with that group. `NOPASSWD:` is the kernel's `NOPASSWD`, `PASSWD:` (the default) asks `approve`, and the command `ALL` is the kernel's `*`. So `cone ALL=(cone-32,cone-33) /usr/local/bin/agent send cone --file /tmp/msg-*.md` reads as `cone (cone-32) Cmnd /usr/local/bin/agent send cone --file /tmp/msg-*.md` and the same for `cone-33`, and `%sudo ALL=(ALL:ALL) ALL` as `%sudo (ALL) Cmnd *`. **The command keeps the kernel's stricter meaning, not sudo's:** an unquoted `*` stays within the argument it is in (the line above allows `--file /tmp/msg-a.md` but not `--file /tmp/msg-a.md /etc/shadow.md`, which sudo's `*` would), `?` and `[` are literal, no arguments allows any, and `""` allows none. A `\` escapes the next character (`\,` for a comma in an argument), as in sudoers. Anything else of sudoers is refused rather than guessed at, so it grants nothing: another host, a `%group`, `#uid` or `!user` to run as, `(:group)` alone, a tag other than `NOPASSWD:`/`PASSWD:` (`NOEXEC:`, `SETENV:`, …), an option such as `CWD=` (use the kernel form's `Cwd`, which limits where the caller may run it rather than choosing where it runs), several commands separated by `,` or a `:` that starts another entry (write one line each), a negated `!command`, a wildcard or a directory as the command path, quotes (write a quoted argument in the kernel form), a `#` comment after the rule, a line continued with `\`, a user list, `Defaults`, `*_Alias`, a `#uid` as who, and `@include`/`#include` (`#includedir /etc/sudoers.d` is ignored, since the kernel reads that directory anyway). **Upgrading:** kernels before this skipped every line in this form (with only a `console.warn`), so such a line already in `/etc/sudoers.d` starts to count; `visudo -c` as root lists what the policy holds.
1096
+ - **The sudoers(5) form:** a line can also be written the way sudoers(5) writes one, for the common subset: `<user|%group|ALL> ALL=[(<target>[,<target>…][:<group>…])] [NOPASSWD:|PASSWD:] <command…|ALL>`, with optional spaces around `=`. The host must be `ALL`. The run-as list is user names or `ALL`, root when it is left out, and a line with several (`cone ALL=(cone-32,cone-33) …`) is one rule per user, as if written once per user in the kernel form; a `:<group>` part is accepted and ignored: the kernel's `sudo` has no `-g`, and the rule runs with the target's own groups, so `(root:restricted)` can differ from sudo, which would run it with that group. `NOPASSWD:` is the kernel's `NOPASSWD`, `PASSWD:` (the default) asks `approve`, and the command `ALL` is the kernel's `*`. So `cone ALL=(cone-32,cone-33) /usr/local/bin/agent send cone --file /tmp/msg-*.md` reads as `cone (cone-32) Cmnd /usr/local/bin/agent send cone --file /tmp/msg-*.md` and the same for `cone-33`, and `%sudo ALL=(ALL:ALL) ALL` as `%sudo (ALL) Cmnd *`. **The command keeps the kernel's stricter meaning, not sudo's:** an unquoted `*` stays within the argument it is in (the line above allows `--file /tmp/msg-a.md` but not `--file /tmp/msg-a.md /etc/shadow.md`, which sudo's `*` would), `?` and `[` are literal, no arguments allows any, and `""` allows none. A `\` escapes the next character (`\,` for a comma in an argument), as in sudoers. Anything else of sudoers is refused rather than guessed at, so it grants nothing: another host, a `%group`, `#uid` or `!user` to run as, `(:group)` alone, a tag other than `NOPASSWD:`/`PASSWD:` (`NOEXEC:`, `SETENV:`, …), an option such as `CWD=` (use the kernel form's `Cwd`, which limits where the caller may run it rather than choosing where it runs), several commands separated by `,` or a `:` that starts another entry (write one line each), a negated `!command`, a wildcard or a directory as the command path, quotes (write a quoted argument in the kernel form), a `#` comment after the rule, a line continued with `\`, a user list, `Defaults` other than the [timestamp ones](#sudo-timestamps), `*_Alias`, a `#uid` as who, and `@include`/`#include` (`#includedir /etc/sudoers.d` is ignored, since the kernel reads that directory anyway). **Upgrading:** kernels before this skipped every line in this form (with only a `console.warn`), so such a line already in `/etc/sudoers.d` starts to count; `visudo -c` as root lists what the policy holds.
1097
1097
  - **A line that doesn't parse is never dropped silently:** it is skipped, so it grants nothing, while the rest of the file still counts (as sudo's error recovery does), and every `sudo` and `sudo -l` says so on standard error before anything else. A caller with an effective uid of 0 gets one line per problem, with the file, the line number and what was expected: `sudo: /etc/sudoers.d/memory-handoff:1: line skipped: the SETENV: tag isn't supported: only NOPASSWD: and PASSWD: are`, or, for a line in neither form, `expected '[NOPASSWD] <user|%group|ALL> [(<target>)] [Cwd <dir>] [Stdin none] Cmnd <command…|*>' or '<user|%group|ALL> ALL=[(<target>[,<target>…])] [NOPASSWD:] <command…|ALL>'`. A policy file that isn't root's, is a symlink, or is writable by group or others, and every file in a directory that is, is skipped the same way and reported as `sudo: <path> skipped: it must be root's, no symlink, and writable by root only`. Any other caller can't read the policy, so it gets only a count, without file names or anything from the lines: `sudo: 2 policy lines or files were skipped and grant nothing; ask an administrator to run sudo visudo -c`, and nothing when nothing was skipped. To check a line before installing it, run `visudo -c -f <file>` on your own copy, which reports it in full. Each problem is also logged once, in full, with `console.warn`.
1098
- - **`visudo -c`** is a command of the kernel itself, in `/usr/bin`, that checks the policy without running anything: `visudo -c -f <file>` (or `visudo -c <file>`, several allowed, `-` for standard input, shown as `stdin`) checks a file the caller can read, before it is installed, and prints `<file>: parsed OK` on standard output, or `visudo: <file>:<line>: <what was expected>` on standard error for each bad line, and exits with 1. `visudo -c` alone checks `/etc/sudoers` and the files in `/etc/sudoers.d` as `sudo` reads them, the ownership and mode included (`visudo: <path>: sudo skips it: …`), and needs an effective uid of 0, as reading those files does on Linux: `sudo visudo -c`. `-q` prints nothing and only sets the status, `-s` is accepted (the check is always strict), and `visudo` without `-c` refuses to edit: write the file as root with mode `0440` (`install -m 0440 -o root -g root <file> /etc/sudoers.d/<name>`).
1098
+ - **`visudo -c`** is a command of the kernel itself, in `/usr/bin`, that checks the policy without running anything: `visudo -c -f <file>` (or `visudo -c <file>`, several allowed, `-` for standard input, shown as `stdin`) checks a file the caller can read, before it is installed, and prints `<file>: parsed OK` on standard output, or `visudo: <file>:<line>: <what was expected>` on standard error for each bad line, and exits with 1. `visudo -c` alone checks `/etc/sudoers` and the files in `/etc/sudoers.d` as `sudo` reads them, the ownership and mode included (`visudo: <path>: sudo skips it: …`), and needs an effective uid of 0, as reading those files does on Linux: `sudo visudo -c`. After the files, it prints the [timestamp values](#sudo-timestamps) on standard output: `Defaults timestamp_timeout=<minutes>, timestamp_type=<type>` for everyone (the kernel's 5 and `tty` where no line sets them), then each `Defaults:<user|%group>` line, as read, in the order they apply, so a later one wins; `-f` prints them only when the files have a `Defaults` line. `-q` prints nothing and only sets the status, `-s` is accepted (the check is always strict), and `visudo` without `-c` refuses to edit: write the file as root with mode `0440` (`install -m 0440 -o root -g root <file> /etc/sudoers.d/<name>`).
1099
1099
  - **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.
1100
1100
  - **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.
1101
1101
  - **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`).
1102
- - **Approval:** `approve(req, { signal })` gets `{ id, kind: 'sudo', user, uid, target, argv, path, cwd, pid, tag?, tty?, createdAt, expiresAt, always, stdin, rootOnly }`: 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. `always` is whether an `'always'` answer can be recorded: `false` when the argv, the path or the directory has a control character (`\p{Cc}`, U+0085 included) or a line or paragraph separator (U+2028, U+2029), which a policy line can't hold, and `true` otherwise; nothing else sets it. `stdin` (`SudoStdin`) is what the caller's standard input is: `'tty'` (a terminal, possibly a pseudo-terminal a program drives), `'none'` (closed or `/dev/null`), `'pipe'` (a pipe, socket or other stream) or `'file'` (a file or another device). `rootOnly` maps the directory, and each argument (and the value of each `--opt=value`) that is an absolute path or names an existing path relative to the directory, resolved against it (`..` collapsed, links not followed), to whether it reaches that path through no symlink (none before a `..` either) and that path, or its nearest existing parent if it is missing, and every directory above it are root's, no symlink, and writable by root only (the test that policy files and the command itself must pass), at most 64 entries from the first 256 arguments. These are facts for the approver, which decides what to offer; the kernel keeps no list of commands. It answers `'once'` (run it), `'always'` (run it, and append `NOPASSWD <user> (<target>) Cwd <dir> Stdin none Cmnd <path> <args…>` to `/etc/sudoers.d/<user>`, each argument a literal (a `*` written as `\*`) and `""` for a call without arguments, root's `0440`, so it's never asked again for this command, these arguments, in this directory, with no input or at a terminal; if that can't be written, nothing runs and `sudo` fails with `sudo: the rule could not be recorded`. A command line whose `always` was `false` runs once and records nothing, and `sudo` prints `sudo: ran once; this command line can't be recorded as always` on standard error before the command starts) or `'deny'`, or a denial that says who refused and why, `{ answer: 'deny', message: 'sudo: denied by the approval judge: <reason>' }` (`SudoDenial`): `sudo` prints the message (`EACCES`) on one line, with line breaks and tabs turned into spaces, other control characters removed, and cut to 500 characters (the last one `…`), when it starts with `sudo: ` and says something after it; otherwise, and for a throw, any other answer or no hook at all, it prints `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. `sudo: the request was withdrawn` means it was interrupted before any answer came: an answer the hook gave before the abort (its promise had settled) is reported, so a denial still prints its message, while `'once'` or `'always'` runs nothing once the requester has gone, and reads withdrawn (or expired); an answer after the abort is ignored. 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 (a denial as `{ answer, message }`, without any other field), and the abort too, and what counts as first is what reaches the kernel worker first; `terminate()` or a failed worker aborts every request still waiting with `'withdrawn'`.
1102
+ - **Approval:** `approve(req, { signal })` gets `{ id, kind: 'sudo', user, uid, target, argv, path, cwd, pid, tag?, tty?, createdAt, expiresAt, always, stdin, rootOnly, validate?, timestamp: 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. `always` is whether an `'always'` answer can be recorded: `false` when the argv, the path or the directory has a control character (`\p{Cc}`, U+0085 included) or a line or paragraph separator (U+2028, U+2029), which a policy line can't hold, and `true` otherwise; nothing else sets it. `stdin` (`SudoStdin`) is what the caller's standard input is: `'tty'` (a terminal, possibly a pseudo-terminal a program drives), `'none'` (closed or `/dev/null`), `'pipe'` (a pipe, socket or other stream) or `'file'` (a file or another device). `rootOnly` maps the directory, and each argument (and the value of each `--opt=value`) that is an absolute path or names an existing path relative to the directory, resolved against it (`..` collapsed, links not followed), to whether it reaches that path through no symlink (none before a `..` either) and that path, or its nearest existing parent if it is missing, and every directory above it are root's, no symlink, and writable by root only (the test that policy files and the command itself must pass), at most 64 entries from the first 256 arguments. These are facts for the approver, which decides what to offer; the kernel keeps no list of commands. `timestamp: true`, on every request, says this kernel takes the answer `{ answer: 'once' | 'always', timestamp: true }` (`SudoApproval`), which means a person approved, and `validate: true` marks the request of a `sudo -v`, with `argv: []`, `path: ''`, `always: false` and `rootOnly: {}`: it asks to start a timestamp for `user` and runs nothing (see [Sudo timestamps](#sudo-timestamps)). It answers `'once'` (run it), `'always'` (run it, and append `NOPASSWD <user> (<target>) Cwd <dir> Stdin none Cmnd <path> <args…>` to `/etc/sudoers.d/<user>`, each argument a literal (a `*` written as `\*`) and `""` for a call without arguments, root's `0440`, so it's never asked again for this command, these arguments, in this directory, with no input or at a terminal; if that can't be written, nothing runs and `sudo` fails with `sudo: the rule could not be recorded`. A command line whose `always` was `false` runs once and records nothing, and `sudo` prints `sudo: ran once; this command line can't be recorded as always` on standard error before the command starts) or `'deny'`, or a denial that says who refused and why, `{ answer: 'deny', message: 'sudo: denied by the approval judge: <reason>' }` (`SudoDenial`): `sudo` prints the message (`EACCES`) on one line, with line breaks and tabs turned into spaces, other control characters removed, and cut to 500 characters (the last one `…`), when it starts with `sudo: ` and says something after it; otherwise, and for a throw, any other answer or no hook at all, it prints `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. `sudo: the request was withdrawn` means it was interrupted before any answer came: an answer the hook gave before the abort (its promise had settled) is reported, so a denial still prints its message, while `'once'` or `'always'` runs nothing once the requester has gone, and reads withdrawn (or expired); an answer after the abort is ignored. 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 (a denial as `{ answer, message }` and a person's approval as `{ answer, timestamp: true }`, without any other field; any other object reads as a denial, as before), and the abort too, and what counts as first is what reaches the kernel worker first; `terminate()` or a failed worker aborts every request still waiting with `'withdrawn'`.
1103
1103
  - **What `'always'` covers:** the same command with the same arguments in the same directory, with no input or at a terminal, so a relative argument (`tee config.toml`) can't be pointed elsewhere by running it from another directory, and piped or redirected input (a pipe, a file) asks again; a terminal counts as no input, even one a program drives through a pseudo-terminal. It doesn't make a command that writes, or an interpreter (`bash -c`, `tee`, `cp`), safe to allow: what such a command touches depends on the files it is given, which the caller may control (a symlink, a file it owns). Keep `'always'` to commands that are safe in any state of the files they're given. Lines that kernels before `Cwd` recorded have no `Cwd`, so they match in any directory; to drop them, rewrite `/etc/sudoers.d/<user>` as root.
1104
1104
  - **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, in a **process group of its own** in the caller's session, as sudo(8) runs it without a pty. When `sudo` has the terminal's foreground, it hands the terminal to the command's group, so `^C`, `^\` and `^Z` reach the command, and takes it back when the command stops or ends. `sudo` exits with the command's status, and passes `SIGTERM`, `SIGHUP`, `SIGINT`, `SIGQUIT`, `SIGUSR1`, `SIGUSR2` and `SIGALRM` on to the command's whole group, so its children get them too; it installs these relays before it asks, so one that comes while the command starts is passed on once it runs (the kernel lets `sudo` signal the group of the command it started, which its own user may not). When the command stops (`^Z`), `sudo` stops by the same signal, so the shell's `jobs`, `fg` and `bg` work on it; when it is continued, it hands the terminal back to the command if it is in the foreground (`fg`) and continues the command's group. A `sudo` killed with `-9` leaves the command running in its group, as sudo(8) does, and the shell takes the terminal back as for any job;
1105
1105
  - **Symlinks:** a command `sudo` started, and every process it starts in turn, follows a symlink only when root or the target user owns it: Linux's `protected_symlinks`, applied in every directory rather than only in sticky world-writable ones. Any other link is `EACCES`, and the denial names the link. That holds in every runtime: in the kernel's own path walk (JS programs' `ctx.fs` and `ctx.open`, WASI paths, `exec` and `cd` by path), in an Emscripten program's lookups (where the denial's op is `follow`), and where a WASI program resolves `..` itself. So a link the caller put where a rule's file or directory is expected (`/tmp/k4l` → `/etc/fnox`, or `config.toml` → `/etc/fnox/config.toml` in the caller's own directory) leads nowhere. `readlink`, `ls -l`, `lstat`, and removing or renaming a link still work, since they don't follow it.
1106
1106
  - **Cost:** with `users: 'off'`, and with owners on for programs that don't call `sudo`, nothing is added to any call. The symlink check costs those processes one owner compare per link in the kernel's walk (the owner is already loaded) and one kernel call per link an Emscripten program follows; other processes pay nothing for it.
1107
1107
 
1108
+ ### Sudo timestamps
1109
+
1110
+ As in sudo(8), a person's approval is remembered for a few minutes: until then, `sudo` runs what the policy lets the caller ask for without asking again. The kernel keeps these timestamps in memory, so a restart of the kernel clears them, as a reboot clears sudo's.
1111
+
1112
+ - **Only a person's approval writes one.** The approver says so by answering `{ answer: 'once', timestamp: true }` or `{ answer: 'always', timestamp: true }` (`SudoApproval`; every request carries `timestamp: true` to say the kernel takes it). A plain `'once'` or `'always'` runs the command and writes nothing, so an approver from before this, a judge or another program can't open the window. `'always'` writes it only once its rule is recorded (or, for a command line that can't be recorded, once it ran).
1113
+ - **What it skips:** only the question. The policy, the checks of the directory and the command, and `NOPASSWD` stay as they are, so a timestamp never lets a caller run a command no line allows it to ask for. A caller with an effective uid of 0, and a command a `NOPASSWD` line allows, neither use nor write one. Every run a timestamp allowed is refreshed (the window starts again from it, as sudo updates its record on each run) and logged on the kernel's console, through the same `console.warn` sudo already logs skipped policy lines to, in sudo's format: `sudo: cone : TTY=unknown ; PWD=/home/cone ; USER=root ; TIMESTAMP=ppid ; COMMAND=/usr/bin/id -un`. Nothing else shows it.
1114
+ - **Whose:** a timestamp belongs to the user who asked (the caller's real uid), whoever the command runs as, and to the place `timestamp_type` names:
1115
+ - `tty` (the default): the caller's controlling terminal and its session, kept with the session leader's start time, so every command typed in one terminal shares it, a nested shell included, and nothing outside the terminal does. A caller without a terminal (an agent's `kernel.run`, a cron job, a pipe) falls back to `ppid`, as sudo does.
1116
+ - `ppid`: the process that ran `sudo`, kept with its start time. So a script or a loop of `sudo` calls asks once, and the next script, another shell or the next `kernel.run` asks again. A `sudo` with no parent in the kernel (one the embedder started, or one a shell the embedder started execs as its last command, as `bash -c 'a; sudo b'` does) counts that top process as its parent, so the last `sudo` of a script shares the script's timestamp; one whose parent has ended uses and writes none.
1117
+ - `global`: every session of that user.
1118
+ Because each record keeps the start time of the process it names, a later process that gets the same pid finds nothing.
1119
+ - **How long:** `timestamp_timeout` minutes after the last run, 5 when no line sets it. Decimals count (`2.5`), `0` always asks and writes nothing, and a negative value never expires until the kernel restarts.
1120
+ - **The settings** are sudoers(5) `Defaults` lines, in `/etc/sudoers` and `/etc/sudoers.d/*` with the same trust checks as the rules: `Defaults timestamp_timeout=10`, `Defaults timestamp_type=global`, several at once separated by commas (`Defaults timestamp_timeout=2.5, timestamp_type=ppid`), `!timestamp_timeout` for 0, and the same for some users with `Defaults:<user|%group|ALL>[,…]` (`Defaults:cone timestamp_type=global`, `Defaults:%sudo timestamp_timeout=0`). As in sudoers(5), the lines for everyone count first and then the lines for the caller (by name or one of its groups), each set in file order, so a later line wins over an earlier one and a line for the caller wins over any line for everyone. Any other `Defaults` (`env_reset`, `Defaults@host`, `Defaults>root`, `Defaults!cmnd`, `timestamp_type=kernel`, `+=`) is refused as a line that doesn't parse: it changes nothing, and `sudo`, `sudo -l` and `visudo -c` report it. `sudo -l` shows the lines for the caller first, as sudo does (`Matching Defaults entries for cone on slicc:` and their values), and `visudo -c` lists the values that apply.
1121
+ - **The flags,** as in sudo(8):
1122
+ - `sudo -n <command>` runs inside the window and fails with `sudo: a password is required` outside it, without asking.
1123
+ - `sudo -v` asks a person to start a timestamp, and runs nothing: the request has `validate: true`, `argv: []` and `path: ''`, and an approver should show it as starting a sudo timestamp for `user`, with no command. Approved by a person, it writes or refreshes the timestamp; inside the window it only refreshes it, without asking; with `-n` outside the window it fails without asking. A plain `'once'` exits 0 but writes nothing, and says so on standard error (`sudo: approved, but the approver didn't say a person decided, so no timestamp was recorded`). A caller whose lines are all `NOPASSWD`, or with an effective uid of 0, succeeds at once; one no line allows anything fails with `Sorry, user <user> may not run sudo on <host>.`
1124
+ - `sudo -k` ends the current timestamp (the one `timestamp_type` names for the caller). `sudo -k <command>` (and `-kv`, `-kl`) ignores the timestamp, asks as if there were none, and writes none, leaving the current one as it was.
1125
+ - `sudo -K` removes every timestamp of the caller. `-k` and `-K` need no line in the policy, and with users `'off'` or `'report'`, `-k`, `-K` and `-v` do nothing and succeed.
1126
+ - **In tests,** `createNodeKernel({ sudo: { clock } })` reads the time of timestamps from `clock.now()`, such as `fakeCronClock()` from `@ai-ecoverse/slicc-kernel/testing`; otherwise it is the monotonic `performance.now()`.
1127
+ - **Upgrading:** kernels before this asked for every `sudo` that needed approval. An approver that answers `{ answer, timestamp: true }` now opens a 5-minute window, so a second `sudo` in the same shell, script or terminal doesn't ask, and `sudo -n` succeeds inside it. Put `Defaults timestamp_timeout=0` in the policy to keep asking every time. Plain string answers behave as before. `visudo -c` now ends with the timestamp values.
1128
+
1108
1129
  ### Cron
1109
1130
 
1110
1131
  The kernel runs scheduled jobs itself, with no daemon process: a cron service starts with the kernel and stops with `terminate()`. It reads `/etc/crontab` and the per-user tables in `/var/spool/cron/crontabs/<user>`, and runs each due job as `$SHELL -c '<command>'` (`/bin/bash` unless the table sets `SHELL`) as the table's user, in that user's home (`/` if the home is missing), with no terminal and `/dev/null` as standard input.
package/dist/index.js CHANGED
@@ -2660,7 +2660,14 @@ function relayable(answer) {
2660
2660
  if (typeof answer === "symbol" || typeof answer === "function") return "deny";
2661
2661
  if (typeof answer !== "object" || answer === null) return answer;
2662
2662
  try {
2663
- const { answer: kind, message } = answer;
2663
+ const {
2664
+ answer: kind,
2665
+ message,
2666
+ timestamp
2667
+ } = answer;
2668
+ if ((kind === "once" || kind === "always") && timestamp === true) {
2669
+ return { answer: kind, timestamp };
2670
+ }
2664
2671
  return kind === "deny" && typeof message === "string" ? { answer: kind, message } : "deny";
2665
2672
  } catch {
2666
2673
  return "deny";