@ai-ecoverse/slicc-kernel 1.173.0 → 1.173.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/dist/kernel-worker.js +122 -8
- package/dist/kernel-worker.js.map +3 -3
- package/dist/node-process-worker.cjs +1 -1
- package/dist/node-process-worker.js +1 -1
- package/dist/node.js +122 -8
- package/dist/node.js.map +3 -3
- package/dist/process-worker.js +1 -1
- package/dist/types/sudo.d.ts +6 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1078,11 +1078,11 @@ With `users: 'enforce'` the kernel checks every file call that reaches it agains
|
|
|
1078
1078
|
`sudo` is a command of the kernel itself, in `/usr/bin` (a package's `sudo` never wins over it): `sudo [-n] [-u user] [--] command [args…]` runs `command` as `user` (root by default), and `sudo -l` lists what the caller may run. `-n` never asks: a command that needs approval fails with `sudo: a password is required`. The decision, the credentials and the environment are all made in the kernel; the command only passes its arguments on (`ctx.sudo`, see [JS programs](#js-programs)), so no program can grant itself more by calling the kernel some other way.
|
|
1079
1079
|
|
|
1080
1080
|
- **`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.
|
|
1081
|
-
- **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>] 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, and the command,
|
|
1081
|
+
- **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>] 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, 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, 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>`. `#` lines and blank lines are ignored. A line that doesn't parse is skipped, and so is a policy file that isn't root's, is a symlink, or is writable by group or others, or one in a directory that is (each logged once with `console.warn`): what's skipped grants nothing. Groups are the caller's process groups, as `id` shows them.
|
|
1082
1082
|
- **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.
|
|
1083
1083
|
- **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.
|
|
1084
1084
|
- **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`).
|
|
1085
|
-
- **Approval:** `approve(req, { signal })` gets `{ id, kind: 'sudo', user, uid, target, argv, path, cwd, pid, tag?, tty?, createdAt, expiresAt, always: true }`: the caller's name and real uid, the target user, the argv as typed, the resolved `path`, the caller's directory, pid, tag and terminal, and the times in ms. It answers `'once'` (run it), `'always'` (run it, and append `NOPASSWD <user> (<target>) Cwd <dir> Cmnd <path> <args…>` to `/etc/sudoers.d/<user>`, root's `0440`, so it's never asked again for this command, these arguments, in this directory; if that can't be written, nothing runs) or `'deny'`; a throw, any other answer or no hook at all is `sudo: approval denied`. After 5 minutes the request expires (`signal` aborts with `'expired'`, and `sudo` fails with `sudo: the request expired`); when the requester ends or gets a signal while it waits, `^C` included, `signal` aborts with `'withdrawn'` and `sudo` ends with that signal's status. A user has at most one request waiting; another fails with `sudo: another request is pending`. On the page, `createKernel` relays the request from the kernel worker and back, and the abort too; `terminate()` or a failed worker aborts every request still waiting with `'withdrawn'`.
|
|
1085
|
+
- **Approval:** `approve(req, { signal })` gets `{ id, kind: 'sudo', user, uid, target, argv, path, cwd, pid, tag?, tty?, createdAt, expiresAt, always: true }`: the caller's name and real uid, the target user, the argv as typed, the resolved `path`, the caller's directory, pid, tag and terminal, and the times in ms. It answers `'once'` (run it), `'always'` (run it, and append `NOPASSWD <user> (<target>) Cwd <dir> 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; if that can't be written, nothing runs) or `'deny'`; a throw, any other answer or no hook at all is `sudo: approval denied`. After 5 minutes the request expires (`signal` aborts with `'expired'`, and `sudo` fails with `sudo: the request expired`); when the requester ends or gets a signal while it waits, `^C` included, `signal` aborts with `'withdrawn'` and `sudo` ends with that signal's status. A user has at most one request waiting; another fails with `sudo: another request is pending`. On the page, `createKernel` relays the request from the kernel worker and back, and the abort too; `terminate()` or a failed worker aborts every request still waiting with `'withdrawn'`.
|
|
1086
1086
|
- **What `'always'` covers:** the same command with the same arguments in the same directory, so a relative argument (`tee config.toml`) can't be pointed elsewhere by running it from another directory. 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.
|
|
1087
1087
|
- **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;
|
|
1088
1088
|
- **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.
|
package/dist/kernel-worker.js
CHANGED
|
@@ -8740,7 +8740,7 @@ function satisfies(version, range2) {
|
|
|
8740
8740
|
}
|
|
8741
8741
|
|
|
8742
8742
|
// src/version.ts
|
|
8743
|
-
var KERNEL_VERSION = true ? "1.173.
|
|
8743
|
+
var KERNEL_VERSION = true ? "1.173.1" : "0.0.0-dev";
|
|
8744
8744
|
var OS_RELEASE = "6.0.0-slicc";
|
|
8745
8745
|
function procVersion() {
|
|
8746
8746
|
return `Linux version ${OS_RELEASE} (slicc-kernel ${KERNEL_VERSION}) #1 SMP PREEMPT_DYNAMIC
|
|
@@ -31777,6 +31777,104 @@ function splitWords(text) {
|
|
|
31777
31777
|
function quoteWord(word) {
|
|
31778
31778
|
return PLAIN3.test(word) ? word : `'${word.replaceAll("'", `'\\''`)}'`;
|
|
31779
31779
|
}
|
|
31780
|
+
function addPart(word, part) {
|
|
31781
|
+
const last = word.length - 1;
|
|
31782
|
+
if (part === null) {
|
|
31783
|
+
if (word[last] !== null) word.push(null);
|
|
31784
|
+
} else if (typeof word[last] === "string") word[last] += part;
|
|
31785
|
+
else word.push(part);
|
|
31786
|
+
}
|
|
31787
|
+
function addPlain(word, plain3) {
|
|
31788
|
+
for (const [i, part] of plain3.split("*").entries()) {
|
|
31789
|
+
if (i > 0) addPart(word, null);
|
|
31790
|
+
if (part !== "") addPart(word, part);
|
|
31791
|
+
}
|
|
31792
|
+
}
|
|
31793
|
+
function splitPatterns(text) {
|
|
31794
|
+
const piece = /'([^']*)'|"((?:[^"\\]|\\[\s\S])*)"|\\([\s\S])|([^\s'"\\]+)|(\s+)/y;
|
|
31795
|
+
const words2 = [];
|
|
31796
|
+
let word;
|
|
31797
|
+
while (piece.lastIndex < text.length) {
|
|
31798
|
+
const m = piece.exec(text);
|
|
31799
|
+
if (!m) return void 0;
|
|
31800
|
+
const [, single, double, escaped, plain3, space] = m;
|
|
31801
|
+
if (space !== void 0) {
|
|
31802
|
+
if (word !== void 0) words2.push(word);
|
|
31803
|
+
word = void 0;
|
|
31804
|
+
continue;
|
|
31805
|
+
}
|
|
31806
|
+
word ??= [];
|
|
31807
|
+
if (plain3 !== void 0) addPlain(word, plain3);
|
|
31808
|
+
else addPart(word, single ?? (double !== void 0 ? unescaped(double) : escaped));
|
|
31809
|
+
}
|
|
31810
|
+
if (word !== void 0) words2.push(word);
|
|
31811
|
+
return words2;
|
|
31812
|
+
}
|
|
31813
|
+
var literal2 = (pattern) => pattern.map((p) => p ?? "*").join("");
|
|
31814
|
+
var isAny = (pattern) => pattern?.length === 1 && pattern[0] === null;
|
|
31815
|
+
function literalWord(word) {
|
|
31816
|
+
if (!word.includes("*")) return quoteWord(word);
|
|
31817
|
+
const bare = word.replaceAll("*", "");
|
|
31818
|
+
return bare === "" || PLAIN3.test(bare) ? word.replaceAll("*", "\\*") : quoteWord(word);
|
|
31819
|
+
}
|
|
31820
|
+
function patternWord(pattern) {
|
|
31821
|
+
if (pattern.length === 1 && typeof pattern[0] === "string") return literalWord(pattern[0]);
|
|
31822
|
+
return pattern.map((p) => p === null ? "*" : literalWord(p)).join("");
|
|
31823
|
+
}
|
|
31824
|
+
function globMatch(items, subjects, star, one) {
|
|
31825
|
+
let i = 0;
|
|
31826
|
+
let j = 0;
|
|
31827
|
+
let back = -1;
|
|
31828
|
+
let from = 0;
|
|
31829
|
+
while (j < subjects) {
|
|
31830
|
+
if (i < items && star(i)) {
|
|
31831
|
+
back = i++;
|
|
31832
|
+
from = j;
|
|
31833
|
+
} else if (i < items && one(i, j)) {
|
|
31834
|
+
i++;
|
|
31835
|
+
j++;
|
|
31836
|
+
} else if (back >= 0) {
|
|
31837
|
+
i = back + 1;
|
|
31838
|
+
j = ++from;
|
|
31839
|
+
} else return false;
|
|
31840
|
+
}
|
|
31841
|
+
while (i < items && star(i)) i++;
|
|
31842
|
+
return i === items;
|
|
31843
|
+
}
|
|
31844
|
+
function wordMatch(pattern, arg) {
|
|
31845
|
+
if (pattern.every((p) => p !== null)) return literal2(pattern) === arg;
|
|
31846
|
+
const chars = pattern.flatMap((p) => p === null ? [null] : [...p]);
|
|
31847
|
+
const subject = [...arg];
|
|
31848
|
+
return globMatch(
|
|
31849
|
+
chars.length,
|
|
31850
|
+
subject.length,
|
|
31851
|
+
(i) => chars[i] === null,
|
|
31852
|
+
(i, j) => chars[i] === subject[j]
|
|
31853
|
+
);
|
|
31854
|
+
}
|
|
31855
|
+
function commandMatches(cmnd, argv) {
|
|
31856
|
+
if (cmnd === "*") return true;
|
|
31857
|
+
const [path, ...args] = argv;
|
|
31858
|
+
if (path !== cmnd.path) return false;
|
|
31859
|
+
const want = cmnd.args;
|
|
31860
|
+
if (want === void 0) return true;
|
|
31861
|
+
return globMatch(
|
|
31862
|
+
want.length,
|
|
31863
|
+
args.length,
|
|
31864
|
+
(i) => isAny(want[i]),
|
|
31865
|
+
(i, j) => wordMatch(want[i], args[j])
|
|
31866
|
+
);
|
|
31867
|
+
}
|
|
31868
|
+
function commandText(cmnd) {
|
|
31869
|
+
const path = quoteWord(cmnd.path);
|
|
31870
|
+
if (cmnd.args === void 0) return path;
|
|
31871
|
+
if (cmnd.args.length === 0) return `${path} ""`;
|
|
31872
|
+
return [path, ...cmnd.args.map(patternWord)].join(" ");
|
|
31873
|
+
}
|
|
31874
|
+
function recordedCommand(argv) {
|
|
31875
|
+
const [path = "", ...args] = argv;
|
|
31876
|
+
return commandText({ path, args: args.map((a) => [a]) });
|
|
31877
|
+
}
|
|
31780
31878
|
function validWho(who) {
|
|
31781
31879
|
if (who === "ALL") return true;
|
|
31782
31880
|
return NAME3.test(who.startsWith("%") ? who.slice(1) : who);
|
|
@@ -31797,9 +31895,23 @@ function parseRule(line) {
|
|
|
31797
31895
|
if (cwd === "") return void 0;
|
|
31798
31896
|
const rule = { nopasswd: !!nopasswd, who, target, ...cwd !== void 0 ? { cwd } : {} };
|
|
31799
31897
|
if (rest2.trim() === "*") return { ...rule, cmnd: "*" };
|
|
31800
|
-
const
|
|
31801
|
-
|
|
31802
|
-
|
|
31898
|
+
const words2 = splitPatterns(rest2);
|
|
31899
|
+
const [first, ...args] = words2 ?? [];
|
|
31900
|
+
const path = first ? literal2(first) : "";
|
|
31901
|
+
if (!path.startsWith("/")) return void 0;
|
|
31902
|
+
const only = args.length === 1 && args[0]?.length === 1 && args[0][0] === "";
|
|
31903
|
+
if (only && /\s""$/.test(rest2.trim())) return { ...rule, cmnd: { path, args: [] } };
|
|
31904
|
+
return { ...rule, cmnd: args.length === 0 ? { path } : { path, args } };
|
|
31905
|
+
}
|
|
31906
|
+
function recordedBare(line, file, rule) {
|
|
31907
|
+
if (rule.cmnd === "*" || rule.cmnd.args !== void 0 || !rule.nopasswd) return false;
|
|
31908
|
+
if (file !== `${SUDOERS_D}/${rule.who}` || !NAME3.test(rule.who) || !NAME3.test(rule.target)) {
|
|
31909
|
+
return false;
|
|
31910
|
+
}
|
|
31911
|
+
const head = `NOPASSWD ${rule.who} (${rule.target}) `;
|
|
31912
|
+
const cmnd = `Cmnd ${quoteWord(rule.cmnd.path)}`;
|
|
31913
|
+
const cwd = rule.cwd === void 0 ? "" : `Cwd ${quoteWord(rule.cwd)} `;
|
|
31914
|
+
return line === `${head}${cwd}${cmnd}`;
|
|
31803
31915
|
}
|
|
31804
31916
|
function flags(parsed2, arg, next3) {
|
|
31805
31917
|
for (let j = 1; j < arg.length; j++) {
|
|
@@ -31837,7 +31949,7 @@ function parseArgs(args) {
|
|
|
31837
31949
|
return parsed2;
|
|
31838
31950
|
}
|
|
31839
31951
|
function described(rule) {
|
|
31840
|
-
const cmnd = rule.cmnd === "*" ? "ALL" : rule.cmnd
|
|
31952
|
+
const cmnd = rule.cmnd === "*" ? "ALL" : commandText(rule.cmnd);
|
|
31841
31953
|
const cwd = rule.cwd === void 0 ? "" : `CWD=${quoteWord(rule.cwd)} `;
|
|
31842
31954
|
return ` (${rule.target}) ${cwd}${rule.nopasswd ? "NOPASSWD: " : ""}${cmnd}`;
|
|
31843
31955
|
}
|
|
@@ -31883,7 +31995,9 @@ var Sudo = class {
|
|
|
31883
31995
|
const rules = [];
|
|
31884
31996
|
for (const [n, line] of text.split("\n").entries()) {
|
|
31885
31997
|
const rule = parseRule(line);
|
|
31886
|
-
if (rule
|
|
31998
|
+
if (rule && recordedBare(line, path, rule) && rule.cmnd !== "*") {
|
|
31999
|
+
rules.push({ ...rule, cmnd: { path: rule.cmnd.path, args: [] } });
|
|
32000
|
+
} else if (rule) rules.push(rule);
|
|
31887
32001
|
else if (rule === void 0) this.warn(`sudo: ${path}:${n + 1}: bad line skipped: ${line}`);
|
|
31888
32002
|
}
|
|
31889
32003
|
return rules;
|
|
@@ -31979,7 +32093,7 @@ var Sudo = class {
|
|
|
31979
32093
|
if (untrusted2) return untrusted2;
|
|
31980
32094
|
const argv = [path, ...args];
|
|
31981
32095
|
const matching = rules.filter(
|
|
31982
|
-
(rule) => applies(rule) && (rule.target === "ALL" || rule.target === target.name) && (rule.cwd === void 0 || rule.cwd === cwd) && (rule.cmnd
|
|
32096
|
+
(rule) => applies(rule) && (rule.target === "ALL" || rule.target === target.name) && (rule.cwd === void 0 || rule.cwd === cwd) && commandMatches(rule.cmnd, argv)
|
|
31983
32097
|
);
|
|
31984
32098
|
const grant = () => this.grant(self, target, cred, parsed2.command, path, cwd, call.term);
|
|
31985
32099
|
if (root || matching.some((rule) => rule.nopasswd)) return grant();
|
|
@@ -32076,7 +32190,7 @@ ${lines2}
|
|
|
32076
32190
|
const st = await fs.lstat(path).catch(() => void 0);
|
|
32077
32191
|
if (st && !await this.trusted(path)) return false;
|
|
32078
32192
|
const old = st ? await fs.readFile(path) : "";
|
|
32079
|
-
const words2 = argv
|
|
32193
|
+
const words2 = recordedCommand(argv);
|
|
32080
32194
|
const line = `NOPASSWD ${self.name} (${target.name}) Cwd ${quoteWord(cwd)} Cmnd ${words2}
|
|
32081
32195
|
`;
|
|
32082
32196
|
const text = old === "" || old.endsWith("\n") ? `${old}${line}` : `${old}
|