@ai-ecoverse/slicc-kernel 1.178.0 → 1.178.2

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
@@ -602,7 +602,7 @@ The module's default export (or its `main` export) is called with a context and
602
602
  - **Limits:** no revocation checking (neither OCSP nor CRLs, as with Node's default), no session resumption, and the clock is the page's `Date.now()`.
603
603
  - `net.resolve(name, { family, signal })` asks the kernel's resolver, which WASIX `resolve` and Emscripten `getaddrinfo` use too: entries in a real `/etc/hosts`, the kernel's own names (`localhost`, the node name, `host.slicc.internal`) and then the uplink's DNS ([#913](https://github.com/ai-ecoverse/slicc-kernel/issues/913)). It answers the addresses, in the resolver's order, for `family` `4`, `6` or `0` (both, IPv4 first; the default). A name with no answer rejects with a `JsCallError` of code `ENOTFOUND`, as Node's `dns.lookup` does. A lookup the uplink fails or doesn't answer within 10 s rejects with `EAI_AGAIN` (the kernel's `sock-resolve` is `ETIMEDOUT` there, `ENOENT` for no answer), as Node's does for a transient failure, so a client can retry; the failure isn't cached ([#1363](https://github.com/ai-ecoverse/slicc-kernel/issues/1363)). Another family rejects with `EAI_FAMILY`, and an aborted `signal` with its reason.
604
604
  - `websocket(url, { protocols, headers, signal })`, a WebSocket over the kernel's transport (its `websocket`, so `fetchTransport({ webSocket: true })` in a browser), shaped like `WebSocketStream`. It resolves once the socket is open, with `url`, the `protocol` the far end chose (always one the program offered: a transport that hands back a socket with another fails the open with `EPROTO`, while browsers and Node's `WebSocket`, which `fetchTransport()` uses, fail such a handshake themselves without saying why, so the open fails with `ECONNREFUSED`), a `readable` of messages (`string` for text, `Uint8Array` for binary), a `writable` for sending them, `closed` (the `{ code, reason }` of the close, from either side, whether or not the program reads) and `close({ code, reason })`. An `http(s):` URL is taken as `ws(s):`. Sending waits while more than 1 MiB is buffered. A signal aborts the open, and after it the socket. A transport without WebSockets rejects with a `TypeError` whose `cause.code` is `ENOTSUP`. One that cannot connect rejects with the `code` its error carries (an errno, such as `EAI_NONAME`, `ECONNREFUSED`, `ETIMEDOUT` or `EPROTO`), else `ECONNREFUSED`. `ws://host.slicc.internal:port` reaches the real machine's loopback, as it does through the proxy;
605
- - `fetch(input, init)`, the web's `fetch` over the kernel's transport, the one the proxy uses, so the embedder's transport, its WebSocket and proxy settings and the kernel's routes apply. The response body streams as the program reads it. On a transport with `traits.streamingBodies` (below), a `ReadableStream` body (with `duplex: 'half'`, which `Request` requires) streams to it as the program produces it, with no size limit of the kernel's, and a redirect that would send it again (a 307 or 308) rejects with a `TypeError`, as the fetch standard has it. Other bodies (a string, a buffer, a `Blob`, a `FormData`) can be sent again, so they go whole even there. Otherwise the request body is read whole first, and one larger than the transport's `maxRequestBody` is answered `413`, as through the proxy. A signal aborts the request at any stage. Redirects follow the fetch rules: at most 20, a 301 or 302 after a `POST` and a 303 turn into a `GET` without a body, `Authorization` is dropped across origins, and `redirect: 'manual'` returns the redirect itself. On a transport that follows redirects on its own (`fetchTransport()` in a browser) only `'follow'` works: `'manual'` and `'error'` reject with a `TypeError` whose `cause.code` is `ENOTSUP`, since such a transport never shows the program a redirect. A status the transport chose comes back as a response whose body is the reason. An unreachable host, or no transport at all, rejects with a `TypeError` whose `cause.code` is `ECONNREFUSED` or `ENETUNREACH`, as Node's `fetch` does. **A request the transport itself could not make** ([#1714](https://github.com/ai-ecoverse/slicc-kernel/issues/1714)) has `cause.detail` `'transport'` as well: that's `fetchTransport()` in a page whose `fetch` rejected (the host is unreachable, or CORS doesn't allow the request; a page can't tell which). `cause.code` stays `ECONNREFUSED` and `cause.message` stays `net-head: ECONNREFUSED`, so programs that check them see no change. A refused connection has no `detail`, whether on the kernel's own loopback or behind `nodeTransport()`, whose `fetch` gives the reason in its own error. So a program can tell "the browser wouldn't let the page fetch this" from "the server refused" and suggest a transport that CORS doesn't bind. The detail is that literal and nothing else: no reason text and nothing else from the transport's error reaches the program. WASI and Emscripten programs keep what they had: the proxy answers `502` with the transport's reason, and `Module.sliccKernel.http` fails with `ECONNREFUSED`. A `data:` URL is answered by `fetch` itself, as the fetch standard decodes it, without a transport: for any method, a `HEAD` without a body, and its type parsed and serialized as a MIME type (lowercase, no spaces, invalid parameters dropped), as Node's `fetch` answers; any other scheme than `http:` and `https:` rejects with a `TypeError` whose `cause.code` is `EPROTONOSUPPORT`, at the start as for a redirect target. A URL on one of the fetch standard's bad ports (25, 6000 and the rest) rejects too, as Node's `fetch` does: a `TypeError` `fetch failed` whose `cause` is an `Error` `bad port` (no `code`), at the start and as a redirect target, on the kernel's own loopback too ([#822](https://github.com/ai-ecoverse/slicc-kernel/issues/822)). `redirect: 'error'` rejects with a `TypeError` whose `cause` is an `Error` `unexpected redirect`, and a body whose connection ends early fails its read with `TypeError` `terminated` whose `cause` is undici's: `name` `SocketError`, `message` `other side closed`, `code` `UND_ERR_SOCKET`. The request goes out with its headers in the order the program gave them (an object's or an array's order; a `Headers` object's is sorted), lowercase as `Headers` keeps them. The global `fetch` stays withheld;
605
+ - `fetch(input, init)`, the web's `fetch` over the kernel's transport, the one the proxy uses, so the embedder's transport, its WebSocket and proxy settings and the kernel's routes apply. The response body streams as the program reads it. On a transport with `traits.streamingBodies` (below), a `ReadableStream` body (with `duplex: 'half'`, which `Request` requires) streams to it as the program produces it, with no size limit of the kernel's, and a redirect that would send it again (a 307 or 308) rejects with a `TypeError`, as the fetch standard has it. Other bodies (a string, a buffer, a `Blob`, a `FormData`) can be sent again, so they go whole even there. Otherwise the request body is read whole first, and one larger than the transport's `maxRequestBody` is answered `413`, as through the proxy. A signal aborts the request at any stage. Redirects follow the fetch rules: at most 20, a 301 or 302 after a `POST` and a 303 turn into a `GET` without a body, `Authorization` is dropped across origins, and `redirect: 'manual'` returns the redirect itself. On a transport that follows redirects on its own (`fetchTransport()` in a browser) only `'follow'` works: `'manual'` and `'error'` reject with a `TypeError` whose `cause.code` is `ENOTSUP`, since such a transport never shows the program a redirect. A status the transport chose comes back as a response whose body is the reason. An unreachable host, or no transport at all, rejects with a `TypeError` whose `cause.code` is `ECONNREFUSED` or `ENETUNREACH`, as Node's `fetch` does. **A request the transport itself could not make** ([#1714](https://github.com/ai-ecoverse/slicc-kernel/issues/1714)) has `cause.detail` `'transport'` as well: that's `fetchTransport()` in a page whose `fetch` rejected (the host is unreachable, or CORS doesn't allow the request; a page can't tell which). `cause.code` stays `ECONNREFUSED` and `cause.message` stays `net-head: ECONNREFUSED`, so programs that check them see no change. A refused connection has no `detail`, whether on the kernel's own loopback or behind `nodeTransport()`, whose `fetch` gives the reason in its own error. So a program can tell "the browser wouldn't let the page fetch this" from "the server refused" and suggest a transport that CORS doesn't bind. The detail is that literal and nothing else: no reason text and nothing else from the transport's error reaches the program. WASI and Emscripten programs keep what they had: the proxy answers `502` with the transport's reason, and `Module.sliccKernel.http` fails with `ECONNREFUSED`. A `data:` URL is answered by `fetch` itself, as the fetch standard decodes it, without a transport: for any method, a `HEAD` without a body, and its type parsed and serialized as a MIME type (lowercase, no spaces, invalid parameters dropped), as Node's `fetch` answers; any other scheme than `http:` and `https:` rejects with a `TypeError` whose `cause.code` is `EPROTONOSUPPORT`, at the start as for a redirect target. A URL on one of the fetch standard's bad ports (25, 6000 and the rest) rejects too, as Node's `fetch` does: a `TypeError` `fetch failed` whose `cause` is an `Error` `bad port` (no `code`), at the start and as a redirect target, on the kernel's own loopback too ([#822](https://github.com/ai-ecoverse/slicc-kernel/issues/822)). `redirect: 'error'` rejects with a `TypeError` whose `cause` is an `Error` `unexpected redirect`, and a body whose connection ends early fails its read with `TypeError` `terminated` whose `cause` is undici's: `name` `SocketError`, `message` `other side closed`, `code` `UND_ERR_SOCKET`. The request goes out with its headers in the order the program gave them (an object's or an array's order; a `Headers` object's is sorted), lowercase as `Headers` keeps them. **Forbidden request headers** ([#1796](https://github.com/ai-ecoverse/slicc-kernel/issues/1796)): `Cookie`, `Referer`, `Origin` and the rest of the fetch standard's forbidden list go out too, as `curl` sends them: a browser's `Request` drops them from `init.headers` without a word, so `fetch` takes the program's `init.headers` as data and hands them to the transport as given (a `Request` object the program built itself has already lost them in a browser, so pass such headers in `init`). Whether they reach the server is the route's to say (see **Forbidden request headers** under [Network](#network)): the kernel's own loopback, `nodeTransport()` and a transport that takes headers as data (a linked `slicc` exit, an extension's `fetch`) send them, and the page's own `fetch`, which can't, makes `fetch` reject with a `TypeError` `fetch failed` whose `cause.code` is `ENOTSUP`, `cause.detail` is `'forbidden-header'` and `cause.message` names the headers (`net-head: fetch <url> refused: the page's fetch can't send the request headers cookie, referer, so it was not sent: ENOTSUP`); the request is never sent. The global `fetch` stays withheld;
606
606
  - **`fetch` and `websocket` to the kernel's own loopback:** a URL whose host is `127.0.0.0/8`, `0.0.0.0`, `[::1]`, `localhost` or `*.localhost` goes to the kernel's own loopback, as a guest's sockets do (`<port>.kernel.localhost` takes its port from the name). So it reaches whatever listens on that port in the kernel, the CDP facade at `SLICC_CDP_URL` and its `/json` included. It never goes to the transport: with nothing listening, it rejects with a `TypeError` whose `cause.code` is `ECONNREFUSED`, with or without a transport. The kernel speaks HTTP/1.1 and WebSocket (RFC 6455) to the listener itself, and `fetch` handles the redirects and compressed bodies it answers, as for any server. Its request head starts as undici's does, `host` then `connection`, then the program's headers in their order, then `content-length`. It sends `connection: close` where undici sends `keep-alive`, because each request is a connection of its own; a server sees no other difference. A WebSocket's `closed` settles after the closing handshake once the server has closed the connection (RFC 6455 §7.1.4), as undici's `close` event does, or at the latest 5 s after the Close frames. A refused upgrade rejects with a `TypeError` whose `cause` is an `Error` naming the status, `the server answered the upgrade with 503 Service Unavailable`, with `code` `EPROTO`. `https:` and `wss:` there reject with `ENOTSUP`. The listener sees a connection from `127.0.0.1` whose `SO_PEERCRED` is the program's pid, uid and gid, as if the program had connected itself. `host.slicc.internal` still goes to the transport as the real machine's loopback, for `fetch` too, under the transport's policy (`nodeTransport({ hostLoopback })`). A redirect from any other URL into the kernel's loopback rejects with `cause.code` `EACCES`, so a server elsewhere can't steer a program's request to the kernel's own servers; `redirect: 'manual'` still returns it;
607
607
  - `tty`, for programs on a terminal: `getAttr(fd)` and `setAttr(fd, termios)` read and set its termios (`{ c_iflag, c_oflag, c_cflag, c_lflag, c_cc }`, Linux values), `setRaw(fd, raw)` switches raw mode on as libuv (and Node's `setRawMode`) does (no echo, no line editing, no `^C` signal, each key read as it comes) and off again to the termios it had before (per terminal: after raw mode on fds 0 and 1 of one terminal, turning it off on either restores the termios from before the first), `size(fd)` answers `{ columns, rows }`, and `foreground(fd)` and `setForeground(fd, pgrp)` get and set the foreground process group, under the same job-control rules as other programs. A window resize arrives as `SIGWINCH` through `signals`. A terminal the program left raw is set back when the program ends, whether it returns, exits or throws (a program killed by a signal cannot do so). On a descriptor that is not a terminal, each call fails with `ENOTTY`; `sync.tty` has `getAttr`, `setAttr` and `setRaw` in synchronous form, sharing the saved modes with `tty`, so a raw mode set either way is set back when the program ends. `sync.tty.setRaw(fd, raw)` returns only once the terminal's line discipline has the new mode, so input that arrives after it returns is never cooked or echoed under the old mode, as Node's synchronous `setRawMode` promises (#1109);
608
608
  - `exit(status)`, which ends the program once its pending writes are done;
@@ -639,7 +639,8 @@ WASIX programs get `AF_INET`/`AF_INET6` `SOCK_DGRAM` sockets, and `AF_UNIX` ones
639
639
  - Emscripten programs have no datagram sockets yet.
640
640
  - **The proxy** listens on `127.0.0.1:3128` from the first connection on. It takes absolute-form `http:` requests and `CONNECT` tunnels, which it terminates with a certificate for the requested host, issued by the kernel's own CA, then hands each request to the transport. Programs start with `http_proxy`, `https_proxy` (and the upper-case names) pointing at it, `no_proxy` covering loopback and the [node name](#node-name), and `SSL_CERT_FILE`, `CURL_CA_BUNDLE` and `GIT_SSL_CAINFO` pointing at the CA bundle in `/etc/ssl/certs/slicc-kernel-ca.pem`; variables in `env` override them. The CA's key never leaves WebCrypto; it is kept in IndexedDB, in `<metadata>-ca`, or in `slicc-kernel-ca` when `metadata` is the default or `false`, so kernels on one origin share one CA whether or not they keep metadata. `ca` names another database, and `ca: false` keeps a CA in memory for the kernel's lifetime. Each kernel adds its CA to the bundle instead of replacing it, so every kernel on the origin stays trusted; the bundle keeps the newest 16.
641
641
  - **TLS** needs [`@ai-ecoverse/wasm-tls-engine`](https://www.npmjs.com/package/@ai-ecoverse/wasm-tls-engine) installed under `modules`, like a command. Without it, `CONNECT` to port 443 is answered `501` with the reason. A tunnel to another port is accepted, and what the program sends first decides: plain HTTP is served as `http://host:port`, without TLS, and a TLS handshake is terminated as above, or the tunnel closes when it cannot be.
642
- - **The transport** is an object on the page with `traits` (`manualRedirects`, `encodedBodies`, `maxRequestBody`, and `crossOrigin`: `'cors'` or `'any'`) and `fetch(request)`, which answers `{ status, statusText, headers, body, cancel }` with `body` an async iterable of `Uint8Array`, and optionally `url` and `redirected: true` when it followed redirects itself (`fetchTransport()` does), so a JS program's `fetch` reports where it ended. The kernel pulls the body one chunk at a time, so a slow program slows the download. A rejection with a numeric `status` is answered with that status; any other with `502`. A rejection with `detail: 'transport'` says the transport could not make the request at all. `fetchTransport()` sets it when its `fetch` rejects without a `cause.code`, as a browser's does, and a JS program's `fetch` gets it as `cause.detail` (above). Any other `detail` is dropped.
642
+ - **The transport** is an object on the page with `traits` (`manualRedirects`, `encodedBodies`, `maxRequestBody`, and `crossOrigin`: `'cors'` or `'any'`) and `fetch(request)`, which answers `{ status, statusText, headers, body, cancel }` with `body` an async iterable of `Uint8Array`, and optionally `url` and `redirected: true` when it followed redirects itself (`fetchTransport()` does), so a JS program's `fetch` reports where it ended. The kernel pulls the body one chunk at a time, so a slow program slows the download. A rejection with a numeric `status` is answered with that status; any other with `502`. A rejection with `detail: 'transport'` says the transport could not make the request at all. `fetchTransport()` sets it when its `fetch` rejects without a `cause.code`, as a browser's does, and a JS program's `fetch` gets it as `cause.detail` (above). `detail: 'forbidden-header'` says the route can't send a header the request has (below). Any other `detail` is dropped.
643
+ - **Forbidden request headers** ([#1796](https://github.com/ai-ecoverse/slicc-kernel/issues/1796)): a request's headers reach the transport as data, `Cookie`, `Referer`, `Origin` and the rest of the fetch standard's forbidden request headers included, from the proxy (curl, git) as from a JS program's `fetch`. A transport that sends them itself (a linked `slicc` exit, an extension, `nodeTransport()`) passes them on. `fetchTransport()` with the realm's own `fetch` in a browser can't: the page's `fetch` drops them without telling anyone. So it refuses the request before sending it, and rejects with `status: 502`, `code: 'ENOTSUP'`, `detail: 'forbidden-header'`, `headers` (the names, lowercase) and the message `fetch <url> refused: the page's fetch can't send the request header(s) <names>, so it was not sent`, with its `hint` appended. curl and other programs behind the proxy get that as a `502` whose body is the message; a JS program's `fetch` gets it as above; an attached client's `client.fetch` and `client.transport.fetch` reject with the same `message`, `status`, `code` and `detail`. Which headers the page drops is asked of the platform's own `Request`, so it matches the browser. The ones the HTTP stack sets itself from the request are left to it and aren't refused: `Accept-Encoding` (the transport decodes the body), `Connection`, `Content-Length`, `Expect`, `Host` (from the URL), `Keep-Alive`, `TE`, `Trailer`, `Transfer-Encoding` and `Upgrade`; the proxy already drops most of them, as proxies do. `fetchTransport({ fetch })` hands every header to the `fetch` it's given (a `Headers` object built in the page keeps them all), since such a `fetch`, an extension's, may send them; a `fetch` you pass in that ends up in the page's own (a wrapper that only drops `User-Agent`) passes `forbiddenHeaders: 'refuse'` too, so it refuses instead of losing them. `forbiddenHeaders: 'send'` turns the check off. Under Node the platform's `fetch` drops none, so nothing is refused there; it takes `Host` from the URL.
643
644
  - **`User-Agent` in a browser:** `fetchTransport()` leaves the header out when it uses the realm's own `fetch` in a page or a worker, or anywhere else `location` exists. Chromium drops it anyway, while WebKit sends it and preflights the request, which a registry refuses. A `fetch` you pass in gets it, and so does Node.
644
645
  - **Streamed request bodies** ([#238](https://github.com/ai-ecoverse/slicc-kernel/issues/238)): a request comes with its body whole, as `body` (a `Uint8Array`), unless the transport sets `traits.streamingBodies`. Then a body the kernel doesn't hold whole comes as `bodyStream`, an async iterable of `Uint8Array`, never together with `body`. That's an upload through the proxy (`Content-Length` or chunked, curl's `-T`, git's push) and a JS program's `fetch` with a stream body. The kernel reads from the program only as fast as the transport pulls, applies no `maxRequestBody` and reserves no memory for it. A transport with a limit of its own rejects with `status: 413`. When the transport answers before it has read the whole body, the proxy closes the program's connection after the response, since the rest of the body is still on it. A transport without the trait keeps getting whole bodies, capped at its `maxRequestBody`, so existing transports need no change. A transport that wraps another (a router, an injector, a logger) and passes on the inner `traits` must forward `bodyStream`, or not claim `streamingBodies`: a wrapper that rebuilds the request from `url`, `method`, `headers`, `body` and `signal` would drop a streamed upload and still answer. `nodeTransport()` streams (undici's `fetch` with `duplex: 'half'`). `fetchTransport({ streamingBodies: true })` turns it on for a page that knows its targets speak HTTP/2 or later, since Chrome streams a `fetch` upload only there, so it's off by default. The trait is also how an embedder's own transport, such as the `slicc` CLI link's exit, takes streamed uploads; across the page and the kernel's worker, and to attached clients, the body is pulled one chunk at a time. `fetchTransport()` is the plain `fetch` of the page, bound by CORS; its `hint` option is appended to the message of a request it could not make, so a program's 502 can name a way around CORS (`registry.npmjs.org` and jsDelivr allow it); an embedder with a way around CORS passes its own. A response body that delivers no data for `bodyIdleMs` (default 5 minutes; `0` or `Infinity` turns it off) fails with `ETIMEDOUT` instead of waiting forever, which matters because Chrome under heavy load can stop handing a page the rest of a body. A slow body that keeps delivering is never cut off.
645
646
  - **Traits of the embedder's own:** `traits` may carry more fields than the kernel reads. The kernel keeps them, and attached clients get them as they are (`client.transport.traits`, `onTraits`). In TypeScript, `NetworkTraits` types its own fields and leaves any other as `unknown`. One is typed: `exit`, the default exit an embedder routes through, `{ kind, caps }` (`NetworkExit`). `kind` is `'local'`, `'remote'` or `'tailnet'` (or another string), `caps` lists what the exit carries (`['net', 'http', 'ws']`), and further fields are `unknown`. When traits change later, call `kernel.updateTransportTraits()`.
@@ -1092,15 +1093,16 @@ With `users: 'enforce'` the kernel checks every file call that reaches it agains
1092
1093
  `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
1094
 
1094
1095
  - **`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
- - **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` 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
- - **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`.
1096
+ - **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] [Sha256 <file>=<sha256>…] 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, the contents of the command's user-writable files, 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=`. `Sha256 <file>=<sha256>`, one clause per file, pins the command to its contents: the line matches only when the [user-writable files](#sudo) that would run for the command are exactly the files it names, by real path (each a shell-quoted `<file>=<sha256>` word with an absolute path and 64 hex digits), and each still has that sha256. An `'always'` answer writes one for each user-writable file; a line without `Sha256` matches whatever the files hold, and a command with no user-writable files needs none. `sudo -l` shows each as `SHA256=<file>:<sha256>`, after `STDIN=`. 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.
1097
+ - **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 digest (`sha256:<hex> <command>`; use the kernel form's `Sha256`), 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.
1098
+ - **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] [Sha256 <file>=<sha256>…] 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
1099
  - **`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
1100
  - **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
- - **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
- - **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, 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
- - **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.
1101
+ - **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; the caller's `PATH` is never searched, as with sudo's `secure_path`, so a bare name doesn't find a user's own bin directory. The policy decides whether it runs, not who owns it: a command the caller can change runs as the target user when a line allows it. What the kernel adds is a binding to its contents. It looks at 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). Each one that isn't root's, writable by root only and reached through no symlink, with every directory above it the same, is **user-writable**: its real path and sha256 go into the approval request (`userWritable`, below), and an `'always'` answer records them in its rule (`Sha256`). When the command starts, just before the kernel loads it, it hashes those files again; if one differs, is gone, or another user-writable file now runs for the command, nothing runs and `sudo` fails with `sudo: <file> changed since it was approved` (`EPERM`). The modules and glue the kernel loads itself (a wasm module, a JS program's module, an Emscripten glue or imports module) are taken from the bytes it read and hashed then, never from its cache of compiled programs, which keys a file by its size and modification time, so a user-writable module swapped for another of the same size and time runs as approved or not at all. That holds whatever allowed the run (an approval, a `NOPASSWD` line, a [timestamp](#sudo-timestamps), an effective uid of 0): the hashes are the ones `sudo` saw when it decided. An interpreter reads its script after the command starts, so a user who can write a script can still change it between that check and the read; approve a user-writable script only for a user you would let run it as root anyway. Files that are root-only throughout are not hashed, and a command whose files all are behaves as before. `sudo -l` ends the line of a rule whose command is user-writable with ` # user-writable command`. 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.
1102
+ - **Compared with Linux sudo:** sudo(8) runs whatever path the policy allows, whoever owns it, and checks a command's contents only when its sudoers line names a digest (`sha256:<hex> /path`). The kernel runs what the policy allows too, and binds every user-writable file to the hash `sudo` saw, for every run, without a digest in the policy, and for all the files that run for the command (glue, module, `package.json`, script and interpreter), not only the command itself. A hand-written line without `Sha256` for a user-writable path is the admin's choice, as a sudoers line without a digest is: it runs the file as it is when it starts. Only the policy files themselves must be root-only, as only sudo's own files must be on Linux. The fixed `PATH` list, the checks on the caller's directory below, the symlink rule for what the command opens and the tags stay as they were. **Upgrading:** kernels before this refused a command that wasn't root-only, whatever the policy said (`sudo: <file> must be root's, and writable by root only`). Such a command now runs when a line allows it, so a `NOPASSWD` line or a `%sudo` line naming a user-writable path starts to count; an approver should show `userWritable`.
1103
+ - **The decision:** a caller whose effective uid is 0 runs anything (the binding of user-writable files 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`).
1104
+ - **Approval:** `approve(req, { signal })` gets `{ id, kind: 'sudo', user, uid, target, argv, path, cwd, pid, tag?, tty?, createdAt, expiresAt, always, stdin, rootOnly, userWritable?, 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, the directory or a user-writable file's path 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 must pass, and that keeps a command's file from being user-writable), at most 64 entries from the first 256 arguments. `userWritable`, there only when the command has [user-writable files](#sudo), lists each as `{ path, sha256 }` (`SudoFile`), the command first, so the approver can show that the caller can change what it approves, and what it holds now: the command runs only with those contents. 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 [Sha256 <file>=<sha256>…] Cmnd <path> <args…>` to `/etc/sudoers.d/<user>`, each argument a literal (a `*` written as `\*`), `""` for a call without arguments, and one `Sha256` for each user-writable file, root's `0440`, so it's never asked again for this command, these arguments and these contents, in this directory, with no input or at a terminal; if a user-writable file changed while the request waited, nothing is recorded or run (`sudo: <file> changed since it was approved`); 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'`.
1105
+ - **What `'always'` covers:** the same command with the same arguments in the same directory, with no input or at a terminal, and, when the command has user-writable files, with the same contents (once one of them changes, the rule no longer matches and `sudo` asks again, or refuses when no other line allows it), 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
1106
  - **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
1107
  - **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
1108
  - **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.
@@ -223,6 +223,9 @@ function kindsOf(hook) {
223
223
  );
224
224
  }
225
225
 
226
+ // src/kernel/net/transport.ts
227
+ var FORBIDDEN_HEADER = "forbidden-header";
228
+
226
229
  // src/kernel/net/ws-queue.ts
227
230
  var INBOUND_LIMIT = 16 * 1024 * 1024;
228
231
  var MESSAGE_LIMIT = 64 * 1024 * 1024;
@@ -300,7 +303,7 @@ var TransportError = class extends Error {
300
303
  super(message);
301
304
  this.status = status;
302
305
  this.code = code;
303
- this.detail = detail === "transport" ? detail : void 0;
306
+ this.detail = detail === "transport" || detail === FORBIDDEN_HEADER ? detail : void 0;
304
307
  }
305
308
  };
306
309
  function unfollowed(redirect) {