diff --git a/modules/zsh/README.md b/modules/zsh/README.md index df0fbe23..a2e80e0f 100644 --- a/modules/zsh/README.md +++ b/modules/zsh/README.md @@ -8,8 +8,16 @@ The login shell as a module (novox/hq ADR 0176, ADR 0204, to-be 41). account in a zsh login shell in the account's home. It is ended with everything it started after 20 s by default (25 s at most, below the runtime's 30 s call limit). Each stream is cut at 256 KiB and the answer says so in `truncated`. +- **A machine may withhold `execute`** (hq ADR 0268). It runs any command as the operator account, + which can become root without a person. The setting `execute` is `serve` by default; a machine's + layer `withhold` (`mesh-controller.settings` with `module` zsh, `node` the machine, `values` + `{"execute":"withhold"}`, then a push) makes the runtime there offer no `execute` at all. The mesh + writes the value into a root-owned `tools.conf` in the module's directory, which the tools bundle + reads when the runtime starts it; a change restarts the runtime. Anything but `serve` (a wrong value, + no file) withholds it, and says why on stderr and in `zsh_config`'s `execute`. The control-node + withholds it until a call to it needs a person's approval (hq research 039). - Its own tool `zsh_config` shows `~/.zshenv` and `~/.zshrc` as they are, with the mesh block's line - count in each. + count in each, and whether this machine serves `execute`, and why. - Contributes to the account's environment (ADR 0203): `EDITOR` and `VISUAL` (vim), `XDG_CONFIG_HOME`, and `~/.local/bin`, `~/scripts`, `~/scripts/bin` at the start of `PATH`. The holder of `node-environment` (`node-env`) writes them; this module writes no `export` of its own. diff --git a/modules/zsh/module.json b/modules/zsh/module.json index 04fb1d26..43ef8801 100644 --- a/modules/zsh/module.json +++ b/modules/zsh/module.json @@ -16,6 +16,13 @@ "tools": [ "zsh_config" ], + "settings": { + "execute": { + "kind": "preference", + "default": "serve", + "why": "execute runs any command as the operator account, which can become root without a person; a machine whose layer says withhold does not offer it (hq ADR 0268): the control-node, until a call to it needs a person's approval" + } + }, "environment": { "variables": { "EDITOR": "vim", @@ -68,6 +75,21 @@ "type": "user", "name": "${machine:account}", "shell": "/usr/bin/zsh" + }, + { + "id": "state", + "type": "directory", + "place": ".", + "owner": "root", + "mode": "0755" + }, + { + "id": "tools-conf", + "type": "file", + "path": "${dir:state}/tools.conf", + "owner": "root", + "mode": "0644", + "content": "# Written by the mesh (module zsh, hq ADR 0268) from this machine's setting execute: serve or withhold.\n# Read by the zsh tools bundle when the runtime starts it; change the setting, not this.\nexecute=${setting:execute}\n" } ], "build": { @@ -78,7 +100,10 @@ "language": "typescript", "entrypoints": [ "tools/index.js" - ] + ], + "env": { + "ZSH_TOOLS_CONF": "${dir:state}/tools.conf" + } } ] } diff --git a/modules/zsh/shell.ts b/modules/zsh/shell.ts index 85375690..570f35c2 100644 --- a/modules/zsh/shell.ts +++ b/modules/zsh/shell.ts @@ -15,7 +15,7 @@ // and the answer says it was cut. import { spawn } from "node:child_process"; -import { existsSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; import { readFile } from "node:fs/promises"; import { homedir, userInfo } from "node:os"; @@ -177,3 +177,49 @@ export async function zshFile(path: string, cap: number = OUTPUT_CAP_BYTES): Pro content: text.length > cap ? text.slice(0, cap) : text, }; } + +// Whether this machine serves `execute` (novox/hq ADR 0268). The command runs as the operator account, +// which can become root without a person, so a machine may withhold the verb: the control-node does, +// until a call to it needs a person's approval. The choice is the module's setting `execute`, which the +// mesh writes into a root-owned file named by the bundle's word ZSH_TOOLS_CONF; the runtime is +// restarted when that file changes. **Anything but `serve` withholds it** — a wrong value, a missing line +// or an unreadable file — because a verb that roots the machine is not served by accident; the reason +// is said on stderr and in zsh_config. + +/** The bundle's word naming the file the machine's setting is written to. */ +export const TOOLS_CONF_WORD = "ZSH_TOOLS_CONF"; + +export interface ExecuteSetting { + served: boolean; + why: string; +} + +/** The setting as the file's text says it; undefined when there is no file to read. */ +export function executeServed(text: string | undefined): ExecuteSetting { + if (text === undefined) return { served: false, why: "no setting was read, so execute is withheld" }; + let value: string | undefined; + for (const line of text.split("\n")) { + const t = line.trim(); + if (!t || t.startsWith("#")) continue; + const at = t.indexOf("="); + if (at < 0) continue; + if (t.slice(0, at).trim() === "execute") value = t.slice(at + 1).trim(); + } + if (value === "serve") return { served: true, why: "this machine's setting execute is serve" }; + if (value === "withhold") return { served: false, why: "this machine's setting execute is withhold (novox/hq ADR 0268)" }; + if (value === undefined) return { served: false, why: "the setting file says nothing of execute, so it is withheld" }; + return { served: false, why: `the setting execute is ${JSON.stringify(value)}, neither serve nor withhold, so it is withheld` }; +} + +/** The setting as this machine's file says it, read once when the bundle registers its tools. */ +export function readExecuteSetting(env: NodeJS.ProcessEnv): ExecuteSetting { + const path = env[TOOLS_CONF_WORD]?.trim(); + if (!path) return { served: false, why: `no ${TOOLS_CONF_WORD} names the setting file, so execute is withheld` }; + let text: string; + try { + text = readFileSync(path, "utf8"); + } catch (err) { + return { served: false, why: `cannot read ${path} (${(err as Error).message}), so execute is withheld` }; + } + return executeServed(text); +} diff --git a/modules/zsh/test/manifest.test.ts b/modules/zsh/test/manifest.test.ts index 7a3f6bcc..10b8c462 100644 --- a/modules/zsh/test/manifest.test.ts +++ b/modules/zsh/test/manifest.test.ts @@ -69,3 +69,26 @@ test("the tools bundle registers the seat's verb under node-login-shell", () => assert.match(src, /registerModuleTools\("node-login-shell", seatVerbs\)/); assert.doesNotMatch(src, /runuser|"login-shell"/); }); + +test("execute is a per-machine setting, served unless a machine withholds it (ADR 0268)", () => { + assert.deepEqual(Object.keys(m.settings), ["execute"]); + assert.equal(m.settings.execute.kind, "preference"); + assert.equal(m.settings.execute.default, "serve"); + assert.match(m.settings.execute.why, /withhold/); + const dir = resource("state"); + assert.deepEqual(dir, { id: "state", type: "directory", place: ".", owner: "root", mode: "0755" }); + const conf = resource("tools-conf"); + assert.equal(conf.type, "file"); + assert.equal(conf.path, "${dir:state}/tools.conf"); + assert.equal(conf.owner, "root", "the account the runtime runs as may read it, never change it"); + assert.equal(conf.mode, "0644"); + assert.match(conf.content, /^execute=\$\{setting:execute\}$/m); + const [bundle] = m.build.artifacts; + assert.deepEqual(bundle.env, { ZSH_TOOLS_CONF: conf.path }, "the word names the file exactly, so its change restarts the runtime"); +}); + +test("the tools bundle registers execute only where the machine's setting serves it", () => { + const src = readFileSync(new URL("../tools/index.ts", import.meta.url), "utf8"); + assert.match(src, /readExecuteSetting\(env\)/); + assert.match(src, /if \(!setting\.served\)[\s\S]{0,200}return \[\]/); +}); diff --git a/modules/zsh/test/withhold.test.ts b/modules/zsh/test/withhold.test.ts new file mode 100644 index 00000000..d55bbff2 --- /dev/null +++ b/modules/zsh/test/withhold.test.ts @@ -0,0 +1,45 @@ +// Withholding node-login-shell's `execute` on one machine (novox/hq ADR 0268). It runs any command as the +// operator account, which can become root without a person, so the operator withholds it on the +// control-node until a call to it needs a person's approval. The machine's choice is the module's +// setting `execute`, written by the mesh into a root-owned file the tools bundle reads at start; the +// bundle then registers the verb or not, so the machine's runtime does not offer it at all. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { executeServed, readExecuteSetting, TOOLS_CONF_WORD } from "../shell.ts"; + +test("execute is served only when the machine's setting says serve", () => { + assert.equal(executeServed("execute=serve\n").served, true); + assert.equal(executeServed("# the mesh's\nexecute = serve \n").served, true); + const withheld = executeServed("execute=withhold\n"); + assert.equal(withheld.served, false); + assert.match(withheld.why, /withhold/); +}); + +test("anything but serve withholds it: a wrong value, no line, no file", () => { + for (const text of ["execute=yes\n", "execute=\n", "", "other=serve\n", "execute=serve-ish\n"]) { + const got = executeServed(text); + assert.equal(got.served, false, JSON.stringify(text)); + assert.ok(got.why.length > 0, "it says why"); + } + const none = executeServed(undefined); + assert.equal(none.served, false); + assert.match(none.why, /no setting/); +}); + +test("the setting is read from the file the bundle's word names", () => { + const dir = mkdtempSync(join(tmpdir(), "zsh-tools-")); + const file = join(dir, "tools.conf"); + writeFileSync(file, "execute=serve\n"); + assert.equal(readExecuteSetting({ [TOOLS_CONF_WORD]: file }).served, true); + writeFileSync(file, "execute=withhold\n"); + assert.equal(readExecuteSetting({ [TOOLS_CONF_WORD]: file }).served, false); + const missing = readExecuteSetting({ [TOOLS_CONF_WORD]: join(dir, "absent.conf") }); + assert.equal(missing.served, false); + assert.match(missing.why, /cannot read/); + const unnamed = readExecuteSetting({}); + assert.equal(unnamed.served, false); + assert.match(unnamed.why, new RegExp(TOOLS_CONF_WORD)); +}); diff --git a/modules/zsh/tools/index.ts b/modules/zsh/tools/index.ts index 3591d0fd..40ec310b 100644 --- a/modules/zsh/tools/index.ts +++ b/modules/zsh/tools/index.ts @@ -2,11 +2,14 @@ // `zsh_config` (novox/hq ADR 0176, ADR 0204). The node tools runtime launches this bundle as a // process of its own and serves what it registers (ADR 0188, ADR 0193); it runs as the operator // account, so the command runs as that account with nothing switched (shell.ts). +// +// `execute` is registered only where this machine's setting serves it (novox/hq ADR 0268): the +// control-node withholds it, so its runtime offers no such verb and a call to it finds nothing. import { userInfo } from "node:os"; import { join } from "node:path"; import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"; -import { DEFAULT_TIMEOUT_SECONDS, MAX_TIMEOUT_SECONDS, commandEnv, execute, homeOf, timeoutOf, zshFile } from "../shell.js"; +import { DEFAULT_TIMEOUT_SECONDS, MAX_TIMEOUT_SECONDS, commandEnv, execute, homeOf, readExecuteSetting, timeoutOf, zshFile } from "../shell.js"; /** The operator account on this machine, as the mesh told the runtime. */ function account(env: NodeJS.ProcessEnv): string { @@ -14,6 +17,11 @@ function account(env: NodeJS.ProcessEnv): string { } function seatVerbs(env: NodeJS.ProcessEnv): ToolDefinition[] { + const setting = readExecuteSetting(env); + if (!setting.served) { + console.error(`[zsh] node-login-shell's execute is not served on this machine: ${setting.why}`); + return []; + } return [ { name: "execute", @@ -47,11 +55,12 @@ function ownTools(env: NodeJS.ProcessEnv): ToolDefinition[] { return [ { name: "zsh_config", - description: "The operator account's ~/.zshenv and ~/.zshrc on this machine as they are now: each file's lines, how many are the mesh's block, and the content.", + description: "The operator account's ~/.zshenv and ~/.zshrc on this machine as they are now: each file's lines, how many are the mesh's block, and the content; and whether this machine serves node-login-shell's execute, and why.", input: { type: "object", properties: {} }, run: async () => { const home = homeOf(env); - return { account: account(env), zshenv: await zshFile(join(home, ".zshenv")), zshrc: await zshFile(join(home, ".zshrc")) }; + const setting = readExecuteSetting(env); + return { account: account(env), execute: { served: setting.served, why: setting.why }, zshenv: await zshFile(join(home, ".zshenv")), zshrc: await zshFile(join(home, ".zshrc")) }; }, }, ];