@ai-ecoverse/slicc-kernel 1.176.2 → 1.178.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 +48 -9
- package/dist/index.js +8 -1
- package/dist/index.js.map +2 -2
- package/dist/kernel-worker.js +519 -121
- package/dist/kernel-worker.js.map +4 -4
- package/dist/node-process-worker.cjs +1 -1
- package/dist/node-process-worker.cjs.map +2 -2
- package/dist/node-process-worker.js +1 -1
- package/dist/node-process-worker.js.map +2 -2
- package/dist/node.js +515 -116
- package/dist/node.js.map +4 -4
- package/dist/process-worker.js +1 -1
- package/dist/process-worker.js.map +2 -2
- package/dist/types/index.d.ts +2 -1
- package/dist/types/kernel/sealed-oauth.d.ts +74 -0
- package/dist/types/kernel/sealed.d.ts +1 -0
- package/dist/types/launcher.d.ts +5 -0
- package/dist/types/node.d.ts +7 -1
- package/dist/types/process/js/js-context.d.ts +2 -0
- package/dist/types/sudo-timestamp.d.ts +28 -0
- package/dist/types/sudo.d.ts +20 -1
- package/dist/types/sudoers.d.ts +25 -2
- package/package.json +3 -3
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
|
|
|
@@ -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 [-
|
|
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
|
|
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.
|
|
@@ -1128,10 +1149,28 @@ The kernel runs scheduled jobs itself, with no daemon process: a cron service st
|
|
|
1128
1149
|
|
|
1129
1150
|
A character device (`0666`) through which a program talks to the embedder's `sealed` hook (`createKernel({ sealed })`), the secrets vault on the page. Emscripten programs, WASI programs (`std::fs`) and JS programs (`ctx.open`, `ctx.sync.open`, listed by `ctx.fs.readdir('/dev')`) all open it, through the same kernel device, with the same framing, errnos and stamp: a JS program's stamp is built in the kernel from its own process, as for any other. The kernel holds the mode a descriptor was opened with, in every runtime, for its `dup`s and for the copies children inherit through fork, spawn and exec: one opened read-only refuses writes with `EBADF`, and one opened write-only refuses reads. Emscripten programs see it in `/dev` and open it by any path that leads to it (`./sealed` from `/dev`, `..`, a symlink to it or to `/dev`), and `fstat` and `/proc/self/fd` show it as `/dev/sealed`, a character device, after fork and exec too; WASI and JS programs open it by a path with no symlink on the way, and WASI programs by one without `..`.
|
|
1130
1151
|
- **State per open:** each `open()` is independent; `dup` and fork share one, as on Linux.
|
|
1131
|
-
- **One write is one request:** a single UTF-8 JSON object of at most 64 KiB with `v` 1 and a known `op` (`ask`, `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.
|
|
1152
|
+
- **One write is one request:** a single UTF-8 JSON object of at most 64 KiB with `v` 1 and a known `op` (`ask`, `seal`, `placeholder`, `list`, `test`, `request`, `status`, `cancel`, `materialize`, `capabilities`, `oauthGrant`, `oauthStatus`, `oauthRefresh`, `oauthRemove`). The write takes all the bytes or fails: `EMSGSIZE` over 64 KiB, `EINVAL` for anything else that isn't such an object, including an invalid managed OAuth request shape. Managed OAuth also returns `ENOTSUP` at the write for reserved unsupported authentication or field-capture inputs, below. Every other outcome is a JSON reply. The kernel zeroes the bytes it was given once it has parsed them.
|
|
1132
1153
|
- **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.
|
|
1133
1154
|
- **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. The kernel zeroes its copy of a reply once it has been read to the end, dropped or closed.
|
|
1134
|
-
- **The hook:** `signal` aborts with `'timeout'` after 10 s (the reply is then `{ v: 1, error: { code: 'ETIMEDOUT' } }`; an `ask` has 305 s, below), `'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;
|
|
1155
|
+
- **The hook:** `signal` aborts with `'timeout'` after 10 s (the reply is then `{ v: 1, error: { code: 'ETIMEDOUT' } }`; an `ask` has 305 s and the four managed OAuth operations have 45 s, below), `'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 for legacy operations; managed OAuth and `capabilities` use fixed safe errors instead. No hook is `ENOTCONN`. On the page, `createKernel` relays the request from the kernel worker and back, and the abort too, with no time limit of its own, so an `ask` held open for minutes comes back as it was answered.
|
|
1156
|
+
|
|
1157
|
+
**Vault-managed OAuth (v1).** `capabilities`, `oauthGrant`, `oauthStatus`, `oauthRefresh` and `oauthRemove` carry public-client Bearer credentials to the vault; the kernel neither stores a refresh grant nor performs refresh network requests. `SealedOAuthRequest`, `SealedOAuthConfig`, `SealedOAuthIdentity`, `SealedOAuthStatus`, `SealedOAuthState`, `SealedOAuthError` and `SealedOAuthReply` are exported types from both the browser and Node entries. They describe device payloads; the existing hook adds its authoritative `caller`. No raw-grant or direct sealed request API is added to an attached client.
|
|
1158
|
+
|
|
1159
|
+
Probe before beginning a managed sign-in with `{ v: 1, op: 'capabilities' }`. The only successful shapes are `{ v: 1, capabilities: { kernelOAuth: 1 } }` and `{ v: 1, capabilities: {} }`. This is an end-to-end capability, not a kernel promise: compatible bios relays these five operations as the page envelope `{ op: 'sealed', req, stamp: caller, kernelOAuth: 1 }`, and the vault advertises or accepts managed OAuth only with that **outer** compatibility assertion. The assertion is not authorization. A process cannot supply it: nested `kernelOAuth`, `stamp`, `source`, `kind`, grant references and other unexpected request fields are `EINVAL`. A submitted `caller` is ignored and overwritten, including on inherited descriptors. An old kernel or vault can return `EINVAL` for the probe; offline, malformed and version-skewed replies never mean support. Deploy the supporting vault and compatible bios with a published supporting kernel before enabling the managed path; do not silently fall back to access-only sealing after a failed probe.
|
|
1160
|
+
|
|
1161
|
+
A registration is one device write, for example, with fake credentials:
|
|
1162
|
+
|
|
1163
|
+
```json
|
|
1164
|
+
{"v":1,"op":"oauthGrant","name":"EXAMPLE_TOKEN","provider":"example","account":"default","accessToken":"fake-access-token","refreshToken":"fake-refresh-token","tokenType":"Bearer","expiresAt":1800003600000,"oauth":{"tokenUrl":"https://auth.example.invalid/token","clientId":"example-public-client","authMethod":"none","scope":"read","resource":"https://api.example.invalid/"}}
|
|
1165
|
+
```
|
|
1166
|
+
|
|
1167
|
+
- Required fields are `name`, `provider`, `account`, `accessToken`, and `oauth` with `tokenUrl`, `clientId`, `authMethod`. `name` is an environment name, at most 128 characters; `provider` matches `^[a-z0-9][a-z0-9-]{0,127}$`; `account` is a nonempty selector of at most 256 characters with no C0/C1 controls, not an attested human identity. Tokens are nonempty strings. Optional `tokenType` defaults to Bearer and is compared case-insensitively without trimming; another type is `ENOTSUP`. Optional `expiresAt` is a nonnegative safe-integer epoch millisecond number, with the vault enforcing its lifetime limits. Optional `oauth.scope` is a string and `oauth.resource` a nonempty string. The vault validates canonical HTTPS token endpoints, client IDs, resource URLs, metadata secrecy and immutable binding; the kernel validates structure and does not choose endpoints.
|
|
1168
|
+
- Only `authMethod: 'none'` is supported. A different string auth method, or any `auth`, `clientSecret` or `fields` field at the request or OAuth-config level, is `ENOTSUP`, never processed or echoed. Other unexpected fields and wrong types are `EINVAL`. Confidential-client refresh, arbitrary auth headers/templates and captured fields are not supported. Existing confidential/access-only/field-capture paths remain legacy operations; this API does not widen their authority.
|
|
1169
|
+
- Without `refreshToken`, registration makes a managed non-refreshable access record and the vault atomically removes its previous owned grant. Without `expiresAt`, expiry is unknown, not guaranteed infinite validity. The vault owns atomic storage, protected-record replacement, enforced-user management checks, immutable provider/account/endpoint binding, linked-grant ownership and refresh rotation; caller claims never substitute for those checks.
|
|
1170
|
+
- Status, refresh and removal take only `{ v: 1, op: 'oauthStatus' | 'oauthRefresh' | 'oauthRemove', name, provider, account }`. Status does no network I/O; refresh forces at most one vault attempt or shares a concurrent successful refresh; removal soft-deletes the access record and its owned grant, not the upstream provider grant. Neither lookup nor refresh accepts new OAuth metadata. Legacy `placeholder`/`list` replies can carry `oauthManaged: true` for managed access records; the kernel preserves that marker unchanged, so clients must not treat such a record as legacy when the managed API is unavailable.
|
|
1171
|
+
- Grant/status/refresh replies have exactly `{ v: 1, placeholder, state, expiresAt, refreshable }`: a `fnox_proxy_` placeholder followed by 24 lowercase hex digits, state `ready`, `refreshing`, `expired`, `reauth-required`, `removed` or `locked`, `expiresAt` always a nonnegative safe integer or `null`, and boolean `refreshable`. `refreshing` means expired with a refresh source, not proof of current I/O; `locked` promises no usability, and `refreshable` describes structural availability, not network reachability. Removal returns exactly `{ v: 1, removed: true }`. No refresh placeholder, credential, OAuth configuration or provider response body can be returned.
|
|
1172
|
+
- An error is `{ v: 1, error: { code, cause?, message } }`, with code `EINVAL`, `ENOENT`, `EEXIST`, `EACCES`, `EAGAIN`, `ENOTSUP`, `ENOTCONN`, `ETIMEDOUT` or `EIO`, and optional cause `vault-locked`, `oauth-reauth-required` or `oauth-refresh-failed`. The kernel requires own `code` and string `message` fields, ignores supplied message text and generates fixed safe text. A missing, inherited or non-string message is malformed. Unknown codes/causes, unexpected fields, malformed types and a reply whose actual `v` is not 1 become fixed `EIO`; unlike legacy operations, a wrong reply version is never normalized into success. `ENOENT` alone means missing/non-managed; identity conflicts, lock, unavailable transport or other errors cannot justify legacy fallback.
|
|
1173
|
+
- These four operations have a 45-second kernel deadline; `capabilities` retains 10 seconds. Vault has a 40-second receipt-based budget and a bounded refresh lock. Aborts and late-reply discarding do not roll back a committed mutation: the current bios may only withdraw its local pending reply, without cancelling vault work. Never replay initial credentials after lost completion; reconcile later with exact-identity status. A blocked raw read can return `EINTR` without cancelling the request (JS reads restart after handled signals). The vault must reject stale refresh/drop/re-auth commits and publish scrub state before replying; bios must propagate that state before releasing successful mutation completion. These are embedder/vault obligations, not guarantees the kernel can infer from a reply. Old sign-ins whose refresh token was discarded need re-authentication.
|
|
1135
1174
|
|
|
1136
1175
|
**Asking the user (`ask`).** A program asks the user for a secret with `{ v: 1, op: 'ask', name, prompt?, reveal?, replace? }`, and the vault answers it from a dialog. `name` matches `^[A-Za-z_][A-Za-z0-9_]*$` and is at most 128 characters, `prompt` is a string of at most 200 characters (code points), shown to the user as the program's text, and `reveal` and `replace` are booleans; any other field, or a field of another shape, is `EINVAL` at the write (a `caller` or `requester` is replaced, as below).
|
|
1137
1176
|
- **What the hook gets:** `{ v: 1, op: 'ask', name, prompt?, reveal, replace, caller, requester }`, `reveal` and `replace` always there (`false` unless the program set them), `caller` the stamp above, and `requester: { exe, argv, cwd, tty, stdin }`, the kernel's facts about the writing process when the hook is asked: `exe` is `/usr/bin/<name>` for a command of the catalog, a kernel command such as `sealed` included, and otherwise the file `/proc/<pid>/exe` names, with its links resolved; `argv` the process's arguments, as `/proc/<pid>/cmdline` shows them; `cwd` its working directory, with its links resolved; `tty` the terminal it started on (`/dev/ttyN` or `/dev/pts/N`), or `null`; and `stdin` what its standard input is, as for [`sudo`](#sudo): `tty`, `none` (closed or `/dev/null`), `pipe` or `file`. A program can't supply or change any of it. A process the kernel has no record of (none of its own, so only under a custom embedding) gets an `EIO` reply and the hook isn't asked.
|
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 {
|
|
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";
|