/** * The recurrence gate for openspec/changes/unified-management-no-ssh/proposal.md: * **modules never hand-build SSH, and the one sanctioned raw-exec path is * always justified in writing.** * * It carries a second, weaker rule class as well — the namespace-tool rule * below. Read that rule's own comment before touching it: it is hygiene and it * is **not** a boundary, and the two classes in this file are not the same kind * of thing. * * ONE definition of the rules, used by both enforcement points: * * - `apps/celilo/src/policy/no-hand-built-ssh.test.ts` — every in-repo module, * on every `bun test`. * - `apps/celilo/src/module/packaging/build.ts` — every `.netapp` at the * moment it is packaged, including modules that never pass through this * repo's CI (`bun run publish` is a documented escape hatch and runs no * tests). * * Deliberately not two copies. The types this repo keeps re-learning that * lesson on — `HookName` (celilo#821), `HookContext` — were duplicated * declarations that drifted silently because nothing fails when two copies * disagree. A scan rule is worse: the duplicate that drifts is the one that * stops catching things, and a gate that stops catching things looks exactly * like a gate with nothing to catch. */ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'; import { basename, join, relative } from 'node:path'; export interface ScanViolation { /** Path as the operator should see it — relative to the scanned root. */ file: string; /** 1-based line number of the offending line. */ line: number; /** Short rule name, e.g. `raw ssh invocation`. */ rule: string; /** What to do instead. */ hint: string; } /** * How far above a `runAppCommand*` call the justification may sit. * * Calibrated against the real call sites rather than guessed: the convention is * a comment block directly above the call, but a call wrapped in `waitFor(() => * …)` puts the justification above the ENCLOSING statement, a few lines up. A * window covers both without needing to parse TypeScript. Eight lines is the * widest real gap plus headroom; wide enough to miss nothing legitimate, narrow * enough that an unrelated hatch elsewhere in the function cannot launder a * fresh call. */ const ESCAPE_HATCH_LOOKBACK_LINES = 8; /** * The rule name for a module script that reaches the `celilo` CLI as a child * process. Exported because the debt list below and both test files key on it; * a literal repeated in three places is the duplicate that drifts. */ export const JAILED_CLI_SPAWN_RULE = 'jailed hook spawns the celilo CLI'; /** * The rule name for a module script that catches a refused framework call and * carries on with a value it invented (e2e-suite-recovery §4, spec delta * `specs/module-lifecycle/spec.md`). Exported for the same reason as the spawn * rule: the debt list below and the gate's test file key on it. */ export const SWALLOWED_REFUSAL_RULE = 'swallowed framework refusal'; /** * A framework-reaching call, spelled as this repo spells them today: one of * the spawn-family calls naming the `celilo` CLI (the same shape the spawn * rule above keys on — extracted here so the two rules cannot disagree about * what reaches the framework), or a call through `runCelilo`, knot-unbound- * internal's injected CLI runner (wired to `execFileSync('celilo', …)` where * the hook is constructed; the debt list's own reason text names the * "runCelilo wrapper"). * * What it deliberately cannot see, same honesty class as every rule in this * file: a wrapper under any other name, a binary path resolved into a * variable, or a spawn reached through an indirection. A lint, not a boundary. */ const CLI_SPAWN_RE = /\b(?:execSync|execFileSync|execFile|exec|spawnSync|spawn)\s*\(\s*(?:['"`]\s*celilo(?=\s|['"`])|`[^`]*\bcelilo(?=\s|`))/; /** * The spawn rule runs against the WHOLE FILE, not one line at a time. The * per-line pass above could not see a call whose argv wrapped onto the next * line — `execFileSync(\n 'celilo', …` — and that blind spot was live: * celilo-registry setup.ts carried a four-line spawn and iptables * firewall-functions.ts three more, all scanning clean while the gate was * green (found by ce-y0we's reach check on 2026-09-09). The regex itself * needs no change — `\s*` already crosses newlines, and `[^\`]*` never * matches a backtick so a template cannot swallow the rest of the file — it * just has to be applied to text wider than a line. Line numbers come from * the match index, so reports still name the line an author fixes. */ const CLI_SPAWN_BLOCK_RE = new RegExp(CLI_SPAWN_RE.source, 'g'); /** Same wording the rule carried when it ran per line; moved with the pass. */ const CLI_SPAWN_HINT = 'A jailed hook has no celilo binary and no shell, so this dies with ' + '"Executable not found in $PATH" (celilo#1225). Replace the spawn with an ' + 'injected capability — context.secrets/context.config for self-config and ' + 'self-secrets (openspec/changes/hook-owned-state) — or move the operation ' + 'into the framework the way celilo-mgmt on_install did (5e0e624d).'; const FRAMEWORK_CALL_RE = new RegExp(`${CLI_SPAWN_RE.source}|\\brunCelilo\\s*\\(`); const PATTERN_RULES: Array<{ rule: string; re: RegExp; hint: string }> = [ // NOTE: the jailed-CLI-spawn rule is NOT in this array — it runs in its own // whole-file pass in scanModuleScriptSource (CLI_SPAWN_BLOCK_RE), because a // per-line match missed every spawn whose argv wrapped. Everything else // stays per-line. { rule: 'raw ssh invocation (StrictHostKeyChecking)', re: /StrictHostKeyChecking/, hint: 'Use a remote-ops primitive (remoteExec/probe/serviceCtl/…). See MODULE_PRIMITIVES.md.', }, { rule: 'raw ssh invocation (ssh … root@)', re: /\bssh\s+(?:-\S+\s+|\S*root@)/, hint: 'Use a remote-ops primitive, not a hand-built ssh string. See MODULE_PRIMITIVES.md.', }, { rule: "'ssh2' import", re: /(?:from|require\()\s*['"]ssh2['"]/, hint: 'Modules never open their own SSH connection — use the primitives. See MODULE_PRIMITIVES.md.', }, /** * HYGIENE, NOT A MITIGATION (openspec/changes/hook-process-boundary, task 4.2e, * design D9). The boundary is the jail; this line is a courtesy to an honest * author, and describing it as a security control would be a lie in three * separate ways: * * - a hook runs arbitrary code, so anything this pattern catches can be * spelled another way by anyone who wants to; * - the scan walks in-repo `scripts/` and staged `.netapp` payloads, so it * never sees a registry module or an out-of-repo one at all; * - celilo#1014 is a live example of an exemption in this very file reading * wider than its author intended. * * What it is for: a module that shells out to `bwrap`/`unshare`/`nsenter` * gets a namespace it is uid 0 in, and under the jail that either fails or * nests badly. Telling the author at package time beats a confusing runtime * failure on the fleet. Task 4.2d is the related-but-different guarantee, and * it IS load-bearing: `bwrap` is never in the derived mount set. */ { rule: 'namespace escape (bwrap/unshare/nsenter)', re: /\b(?:bwrap|unshare|nsenter)\b|CLONE_NEWUSER/, hint: 'Hooks already run inside a namespace celilo builds; a module creating its own is ' + 'not supported and will not survive the jail. This is a hygiene check, not a security ' + 'boundary. If you need isolation the framework does not give you, say what for.', }, ]; /** * How far below a framework-reaching call the `catch` clause may sit for the * call to count as caught. Calibrated against the three real sites rather than * guessed: the widest call-to-catch gap among them is four lines (on_backup's * machine-pool snapshot parses between the call and the catch). Six covers * those with headroom and cannot reach the next statement's catch in any * module script in the tree. */ const SWALLOW_LOOKAHEAD_LINES = 6; /** * How deep into a catch body the scan looks for a `throw` before concluding * the catch swallows. A refusal that propagates (rethrow, wrapped throw) is * correct handling and must not fire; a catch that returns a literal, logs and * continues, or only comments must. Eight lines is beyond any catch body in * the tree; a longer one hides from this scan the same way a renamed wrapper * does. */ const SWALLOW_CATCH_BODY_LINES = 8; const CATCH_CLAUSE_RE = /\bcatch\b/; const THROW_RE = /\bthrow\b/; const CATCH_CLOSE_RE = /^\s*}\s*(?:catch|finally|else)?\s*;?\s*$/; /** * The catch clause a framework-reaching call landed in swallows when its body * contains no `throw` within the window: the refusal is absorbed and the hook * carries on. Returns false (handled correctly) when the catch propagates the * refusal, and true only for the swallowed shape. */ function catchSwallows(lines: string[], catchIdx: number): boolean { const end = Math.min(catchIdx + SWALLOW_CATCH_BODY_LINES, lines.length - 1); for (let k = catchIdx + 1; k <= end; k++) { if (THROW_RE.test(lines[k])) return false; if (k > catchIdx + 1 && CATCH_CLOSE_RE.test(lines[k])) break; } return true; } /** A `runAppCommand(` / `runAppCommandWithSecret(` CALL — not the import. */ const RAW_EXEC_CALL = /\brunAppCommand(?:WithSecret)?\s*\(/; const ESCAPE_HATCH_MARKER = /escape-hatch:/; /** * Scan one file's source. Pure — takes the text, returns the violations, so it * is testable without a filesystem and reusable over a staged package. */ export function scanModuleScriptSource(file: string, source: string): ScanViolation[] { const violations: ScanViolation[] = []; const lines = source.split('\n'); // The jailed-CLI-spawn pass: whole-file, so a spawn whose argv wraps onto // the next line is still seen (see CLI_SPAWN_BLOCK_RE for the blind spot // that motivated this). Reported on the line the CALL starts, because that // is the site an author fixes. CLI_SPAWN_BLOCK_RE.lastIndex = 0; for (let m = CLI_SPAWN_BLOCK_RE.exec(source); m !== null; m = CLI_SPAWN_BLOCK_RE.exec(source)) { violations.push({ file, line: source.slice(0, m.index).split('\n').length, rule: JAILED_CLI_SPAWN_RULE, hint: CLI_SPAWN_HINT, }); } lines.forEach((text, i) => { for (const { rule, re, hint } of PATTERN_RULES) { if (re.test(text)) violations.push({ file, line: i + 1, rule, hint }); } if (!RAW_EXEC_CALL.test(text)) return; const from = Math.max(0, i - ESCAPE_HATCH_LOOKBACK_LINES); const justified = lines.slice(from, i).some((l) => ESCAPE_HATCH_MARKER.test(l)); if (!justified) { violations.push({ file, line: i + 1, rule: 'unjustified raw-exec escape hatch', hint: 'runAppCommand* is the ONLY sanctioned raw-exec path and every call site must say why ' + 'no capability, HTTP or converge path exists. Add an `// escape-hatch: …` comment ' + 'immediately above the call. See MODULE_PRIMITIVES.md.', }); } }); // The swallowed-refusal pass: windowed, like the escape-hatch check above — // a catch clause this far below a framework-reaching call is the call's // catch. Reported on the CALL line, because that is the site an author fixes. lines.forEach((text, i) => { if (!FRAMEWORK_CALL_RE.test(text)) return; const last = Math.min(i + SWALLOW_LOOKAHEAD_LINES, lines.length - 1); for (let j = i + 1; j <= last; j++) { if (CATCH_CLAUSE_RE.test(lines[j])) { if (catchSwallows(lines, j)) { violations.push({ file, line: i + 1, rule: SWALLOWED_REFUSAL_RULE, hint: 'A refused framework call must fail, not continue with an answer it invented ' + '(e2e-suite-recovery §4): a refusal and an absence are different facts. Let the ' + 'error propagate, or read through a surface that distinguishes them — an injected ' + 'typed-absent reader (openspec/changes/hook-owned-state) — rather than catching ' + 'and substituting.', }); } break; } // A blank line or a closing brace at statement level ends the call's // statement; a catch further down belongs to something else. if (lines[j].trim() === '' || /^[)}]/.test(lines[j])) break; } }); return violations; } /** * Every production `.ts` under a module's `scripts/` — excluding tests and * `node_modules`. * * The exclusion is load-bearing, not tidiness: the shipped closure bundles * `@celilo/capabilities`, whose `remote.ts` builds the `ssh … root@` string * that every one of these rules exists to keep OUT of module code. Scanning it * would fail every module in the fleet on the implementation of the primitives * they were told to use. The package itself is NOT exempt — * `scanCapabilityPackageSource` scans it everywhere except those primitive * files (celilo#1014), which is how `public-web.ts`'s hand-built ssh came to * light. */ export function moduleScriptFiles(scriptsDir: string): string[] { if (!existsSync(scriptsDir) || !statSync(scriptsDir).isDirectory()) return []; const out: string[] = []; const walk = (dir: string) => { for (const entry of readdirSync(dir)) { if (entry === 'node_modules') continue; const p = join(dir, entry); if (statSync(p).isDirectory()) walk(p); else if (p.endsWith('.ts') && !p.endsWith('.test.ts')) out.push(p); } }; walk(scriptsDir); return out; } /** * Scan a module directory (the one holding `manifest.yml`). Returns every * violation in its `scripts/`, with paths relative to `moduleDir`, minus the * jailed-CLI-spawn sites on the debt list. The module id is the directory's * basename — the same assumption `packaging/build.ts`'s refusal message makes. */ export function scanModuleDirectory(moduleDir: string): ScanViolation[] { const moduleId = basename(moduleDir); // One exemption set per rule, keyed the same way: a debt entry silences its // own rule on its own file and nothing else. const debtByRule: Record> = { [JAILED_CLI_SPAWN_RULE]: new Set( JAILED_CLI_SPAWN_DEBT.filter((e) => e.module === moduleId).map((e) => e.file), ), [SWALLOWED_REFUSAL_RULE]: new Set( SWALLOWED_REFUSAL_DEBT.filter((e) => e.module === moduleId).map((e) => e.file), ), }; return moduleScriptFiles(join(moduleDir, 'scripts')) .flatMap((f) => scanModuleScriptSource(relative(moduleDir, f), readFileSync(f, 'utf-8'))) .filter((v) => !(debtByRule[v.rule]?.has(v.file) ?? false)); } /** Render violations for a test failure message or a refused publish. */ export function formatViolations(violations: ScanViolation[]): string { return violations.map((v) => ` ${v.file}:${v.line}\n → ${v.rule}. ${v.hint}`).join('\n'); } export interface PolicyDebtEntry { /** Module directory name under `modules/`. */ module: string; /** Script path relative to the module directory, e.g. `scripts/setup.ts`. */ file: string; /** How many jailed-CLI spawns the file carries today. Pinned so a NEW spawn in an exempted file cannot hide behind the entry. */ matches: number; /** Who converts this site, and into what. */ reason: string; } /** * The known jailed-CLI spawns, exempted from the rule above until their * conversion lands (hook-owned-state task 5.5; e2e-suite-recovery §3). * * These sites are tracked defects, not sanctioned forms: the jail kills every * one of them the same way it killed celilo-mgmt's on_install (5e0e624d). The * list exists so the rule can land RED-proof while the conversion — which * needs the HookStore from hook-owned-state tasks 2 and 3 — is still at 0%. * It is narrow on purpose (celilo#1014's lesson): one entry per file, each * naming its owner, each pinning the exact number of spawns so a new call * site cannot hide behind an existing entry. * * The debt test in `no-hand-built-ssh.test.ts` asserts every entry still * matches its recorded count. When a conversion lands the count drops to zero, * the test goes red, and the entry must be deleted in the same change. */ export const JAILED_CLI_SPAWN_DEBT: readonly PolicyDebtEntry[] = [ { module: 'dnsmasq-dhcp', file: 'scripts/dhcp-server-functions.ts', matches: 1, reason: 'capability provider, no hook entry imports it — ce-mqay (jail does not constrain it; conversion is dedup, not breakage)', }, { module: 'iptables', file: 'scripts/firewall-functions.ts', matches: 3, reason: 'ce-mqay: capability provider, no hook entry imports it. Two config-set writes and one events emit. INVISIBLE to the scan until ce-y0we taught it continuation lines (2026-09-09) — these three were live spawns the green gate could not see', }, ]; /** * The known swallowed refusals, exempted from the rule above until their * conversion lands (hook-owned-state 5.5; e2e-suite-recovery §4). Same * mechanics and same narrowness as the spawn debt above — one entry per file, * each naming its owner, each pinning the exact number of swallows — with the * same guarded rot: when a site converts, the count drops, the debt test goes * red, and the entry must be deleted in the same change. * * EMPTY as of ce-y0we (2026-09-09): knot-unbound-internal's on-install dropped * its last swallow on 2026-09-07 (ce-pvii), wireguard's health-check converted * the same evening (01575380), and celilo-mgmt's on_backup machine-pool read * retired the last entry. A NEW swallowed refusal must never land here — it * gets fixed, not tracked. */ export const SWALLOWED_REFUSAL_DEBT: readonly PolicyDebtEntry[] = []; /** * The files inside `@celilo/capabilities` that implement the remote-exec * primitives themselves. `remote.ts` is the ONE sanctioned place the * `ssh … root@` string is built (its own header says so), so the narrowed * scan exempts these files and nothing else. * * celilo#1014: the old exemption was the whole package — `moduleScriptFiles` * skipped `node_modules` entirely, on the reasoning that scanning the bundle * would fail every module on `remote.ts`. The whole-package shape also * exempted `public-web.ts`, which was hand-building the same ssh string, and * no gate could see it. Naming the primitive files instead of skipping the * package is the fix for that class. */ export const REMOTE_PRIMITIVE_FILES: readonly string[] = ['remote.ts']; /** * The narrowed file set: every production `.ts` in the package source EXCEPT * the primitive-implementation files. Returned separately from the violations * so a gate can prove its own reach — an empty violation list must never be * indistinguishable from "scanned nothing". */ export function capabilityPackageSourceFiles(srcDir: string): string[] { if (!existsSync(srcDir) || !statSync(srcDir).isDirectory()) return []; return readdirSync(srcDir) .filter( (entry) => entry.endsWith('.ts') && !entry.endsWith('.test.ts') && !REMOTE_PRIMITIVE_FILES.includes(entry), ) .sort(); } /** * Scan the `@celilo/capabilities` source tree under the exemption's narrowed * shape: every production `.ts` EXCEPT the files implementing the primitive. * * Takes the directory rather than finding it, so the same function serves both * copies of the package the fleet actually runs: the workspace source * (`packages/capabilities/src`, where the code is authored) and, once the next * version publishes, each module's bundled copy * (`modules//scripts/node_modules/@celilo/capabilities/src`). The bundled * copies on a checkout made before that publish still carry the pre-fix code, * so the in-repo gate reads the workspace source until then — scanning a * snapshot no commit in this repo can fix would leave the gate red in exactly * the PR that repairs the defect. */ export function scanCapabilityPackageSource(srcDir: string): ScanViolation[] { return capabilityPackageSourceFiles(srcDir).flatMap((entry) => scanModuleScriptSource( join('@celilo/capabilities/src', entry), readFileSync(join(srcDir, entry), 'utf-8'), ), ); }