@celilo/cli 2.1.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/drizzle/0031_module_config_source.sql +20 -0
  2. package/drizzle/meta/_journal.json +8 -1
  3. package/package.json +2 -2
  4. package/schemas/system_config.json +2 -1
  5. package/src/capabilities/public-web-publish.test.ts +61 -0
  6. package/src/cli/commands/firewall-interface-list.test.ts +156 -7
  7. package/src/cli/commands/firewall-interface-list.ts +73 -7
  8. package/src/cli/commands/machine-add.ts +12 -55
  9. package/src/cli/commands/module-config.test.ts +20 -1
  10. package/src/cli/commands/module-import.ts +1 -1
  11. package/src/cli/commands/module-update.test.ts +82 -0
  12. package/src/cli/commands/module-update.ts +14 -4
  13. package/src/cli/commands/monitor.ts +2 -10
  14. package/src/cli/commands/restore.ts +16 -6
  15. package/src/cli/generate-zsh-completion.ts +1 -1
  16. package/src/cli/index.ts +4 -3
  17. package/src/cli/restore-migration-failure.test.ts +159 -0
  18. package/src/db/client.ts +5 -0
  19. package/src/db/migrate.test.ts +61 -135
  20. package/src/db/migrate.ts +7 -2
  21. package/src/db/schema.ts +10 -0
  22. package/src/hooks/broker.test.ts +106 -2
  23. package/src/hooks/broker.ts +91 -1
  24. package/src/hooks/capability-loader-firewall.test.ts +37 -0
  25. package/src/hooks/capability-loader.ts +15 -1
  26. package/src/hooks/define-hook.test.ts +4 -3
  27. package/src/hooks/executor.test.ts +19 -18
  28. package/src/hooks/executor.ts +82 -11
  29. package/src/hooks/hook-jail-toolchain-reach.test.ts +3 -2
  30. package/src/hooks/hook-jail-unreachability.test.ts +4 -3
  31. package/src/hooks/hook-protocol.ts +46 -1
  32. package/src/hooks/hook-runner.ts +36 -0
  33. package/src/hooks/hook-store-proxy.test.ts +109 -0
  34. package/src/hooks/hook-store-proxy.ts +85 -0
  35. package/src/hooks/hook-store.test.ts +162 -0
  36. package/src/hooks/hook-store.ts +290 -0
  37. package/src/hooks/hook-timeout.test.ts +3 -2
  38. package/src/hooks/hook-trespass.test.ts +29 -5
  39. package/src/hooks/jail.test.ts +1 -1
  40. package/src/hooks/jail.ts +14 -7
  41. package/src/hooks/mount-set.test.ts +208 -0
  42. package/src/hooks/mount-set.ts +62 -14
  43. package/src/hooks/run-named-hook.ts +2 -0
  44. package/src/hooks/test-fixtures/jail-probe-hook.ts +1 -1
  45. package/src/hooks/test-fixtures/on-restore-staging-hook.ts +26 -0
  46. package/src/hooks/test-fixtures/store-backed.ts +47 -0
  47. package/src/hooks/test-fixtures/store-writing-hook.ts +63 -0
  48. package/src/hooks/unjailed-lint.test.ts +22 -6
  49. package/src/manifest/schema.ts +1 -0
  50. package/src/module/packaging/build.ts +70 -2
  51. package/src/module/web-root.ts +17 -1
  52. package/src/policy/fixture-capability-coverage.test.ts +322 -0
  53. package/src/policy/module-script-scan.test.ts +42 -1
  54. package/src/policy/module-script-scan.ts +275 -5
  55. package/src/policy/no-hand-built-ssh.test.ts +34 -1
  56. package/src/policy/no-swallowed-refusal.test.ts +265 -0
  57. package/src/policy/no-tar-shell-out-in-services.test.ts +43 -0
  58. package/src/registry/client.test.ts +149 -0
  59. package/src/registry/client.ts +203 -11
  60. package/src/services/alerting/format.test.ts +57 -0
  61. package/src/services/alerting/format.ts +24 -0
  62. package/src/services/alerting/run-monitor.ts +2 -2
  63. package/src/services/backup-create.ts +7 -7
  64. package/src/services/backup-envelope-roundtrip.test.ts +45 -2
  65. package/src/services/backup-restore.ts +8 -4
  66. package/src/services/bus-interview.ts +37 -14
  67. package/src/services/config-provenance.ts +4 -0
  68. package/src/services/control-plane-bootstrap.test.ts +121 -1
  69. package/src/services/control-plane-bootstrap.ts +51 -4
  70. package/src/services/deploy-preflight.ts +8 -2
  71. package/src/services/deploy-validation.test.ts +22 -0
  72. package/src/services/deploy-validation.ts +8 -0
  73. package/src/services/dns-discovery.test.ts +54 -0
  74. package/src/services/dns-discovery.ts +47 -5
  75. package/src/services/fleet-key.test.ts +66 -2
  76. package/src/services/fleet-key.ts +54 -0
  77. package/src/services/health-runner.ts +2 -0
  78. package/src/services/module-config.ts +20 -2
  79. package/src/services/module-deploy.dns-repoint.test.ts +187 -0
  80. package/src/services/module-deploy.ts +163 -1
  81. package/src/services/module-validator/git-hygiene.test.ts +122 -3
  82. package/src/services/module-validator/git-hygiene.ts +83 -14
  83. package/src/services/restore-from-file.test.ts +20 -0
  84. package/src/services/restore-from-file.ts +21 -6
  85. package/src/services/static-content-converge.test.ts +140 -2
  86. package/src/services/static-content-converge.ts +55 -8
  87. package/src/services/system-config-schema-types.ts +1 -1
  88. package/src/services/system-config-validator.test.ts +36 -0
  89. package/src/services/system-config-validator.ts +11 -0
  90. package/src/services/trusted-sources.test.ts +30 -0
  91. package/src/services/trusted-sources.ts +47 -10
  92. package/src/templates/generator.ts +9 -2
  93. package/src/variables/context.ts +16 -5
@@ -26,7 +26,7 @@
26
26
  */
27
27
 
28
28
  import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
29
- import { join, relative } from 'node:path';
29
+ import { basename, join, relative } from 'node:path';
30
30
 
31
31
  export interface ScanViolation {
32
32
  /** Path as the operator should see it — relative to the scanned root. */
@@ -52,7 +52,58 @@ export interface ScanViolation {
52
52
  */
53
53
  const ESCAPE_HATCH_LOOKBACK_LINES = 8;
54
54
 
55
+ /**
56
+ * The rule name for a module script that reaches the `celilo` CLI as a child
57
+ * process. Exported because the debt list below and both test files key on it;
58
+ * a literal repeated in three places is the duplicate that drifts.
59
+ */
60
+ export const JAILED_CLI_SPAWN_RULE = 'jailed hook spawns the celilo CLI';
61
+
62
+ /**
63
+ * The rule name for a module script that catches a refused framework call and
64
+ * carries on with a value it invented (e2e-suite-recovery §4, spec delta
65
+ * `specs/module-lifecycle/spec.md`). Exported for the same reason as the spawn
66
+ * rule: the debt list below and the gate's test file key on it.
67
+ */
68
+ export const SWALLOWED_REFUSAL_RULE = 'swallowed framework refusal';
69
+
70
+ /**
71
+ * A framework-reaching call, spelled as this repo spells them today: one of
72
+ * the spawn-family calls naming the `celilo` CLI (the same shape the spawn
73
+ * rule above keys on — extracted here so the two rules cannot disagree about
74
+ * what reaches the framework), or a call through `runCelilo`, knot-unbound-
75
+ * internal's injected CLI runner (wired to `execFileSync('celilo', …)` where
76
+ * the hook is constructed; the debt list's own reason text names the
77
+ * "runCelilo wrapper").
78
+ *
79
+ * What it deliberately cannot see, same honesty class as every rule in this
80
+ * file: a wrapper under any other name, a binary path resolved into a
81
+ * variable, or a spawn reached through an indirection. A lint, not a boundary.
82
+ */
83
+ const CLI_SPAWN_RE =
84
+ /\b(?:execSync|execFileSync|execFile|exec|spawnSync|spawn)\s*\(\s*(?:['"`]\s*celilo(?=\s|['"`])|`[^`]*\bcelilo(?=\s|`))/;
85
+ const FRAMEWORK_CALL_RE = new RegExp(`${CLI_SPAWN_RE.source}|\\brunCelilo\\s*\\(`);
86
+
55
87
  const PATTERN_RULES: Array<{ rule: string; re: RegExp; hint: string }> = [
88
+ {
89
+ rule: JAILED_CLI_SPAWN_RULE,
90
+ // Same regex as CLI_SPAWN_RE below — one definition of "reaches the
91
+ // framework", used by both rules that key on it. Matches the CLI as
92
+ // argv[0] of a spawn-family call ('celilo' / "celilo"), or inside a shell
93
+ // string passed to one, where celilo is followed by a word (a subcommand)
94
+ // or the closing quote — so `docker exec celilo-mgr …`, a `celilo.db` file
95
+ // name, and prose that merely names the CLI do not fire. What it
96
+ // deliberately cannot see: a binary path resolved into a variable, or a
97
+ // spawn reached through an indirection. Same honesty class as the
98
+ // namespace rule — a lint, not a boundary.
99
+ re: CLI_SPAWN_RE,
100
+ hint:
101
+ 'A jailed hook has no celilo binary and no shell, so this dies with ' +
102
+ '"Executable not found in $PATH" (celilo#1225). Replace the spawn with an ' +
103
+ 'injected capability — context.secrets/context.config for self-config and ' +
104
+ 'self-secrets (openspec/changes/hook-owned-state) — or move the operation ' +
105
+ 'into the framework the way celilo-mgmt on_install did (5e0e624d).',
106
+ },
56
107
  {
57
108
  rule: 'raw ssh invocation (StrictHostKeyChecking)',
58
109
  re: /StrictHostKeyChecking/,
@@ -97,6 +148,45 @@ const PATTERN_RULES: Array<{ rule: string; re: RegExp; hint: string }> = [
97
148
  },
98
149
  ];
99
150
 
151
+ /**
152
+ * How far below a framework-reaching call the `catch` clause may sit for the
153
+ * call to count as caught. Calibrated against the three real sites rather than
154
+ * guessed: the widest call-to-catch gap among them is four lines (on_backup's
155
+ * machine-pool snapshot parses between the call and the catch). Six covers
156
+ * those with headroom and cannot reach the next statement's catch in any
157
+ * module script in the tree.
158
+ */
159
+ const SWALLOW_LOOKAHEAD_LINES = 6;
160
+
161
+ /**
162
+ * How deep into a catch body the scan looks for a `throw` before concluding
163
+ * the catch swallows. A refusal that propagates (rethrow, wrapped throw) is
164
+ * correct handling and must not fire; a catch that returns a literal, logs and
165
+ * continues, or only comments must. Eight lines is beyond any catch body in
166
+ * the tree; a longer one hides from this scan the same way a renamed wrapper
167
+ * does.
168
+ */
169
+ const SWALLOW_CATCH_BODY_LINES = 8;
170
+
171
+ const CATCH_CLAUSE_RE = /\bcatch\b/;
172
+ const THROW_RE = /\bthrow\b/;
173
+ const CATCH_CLOSE_RE = /^\s*}\s*(?:catch|finally|else)?\s*;?\s*$/;
174
+
175
+ /**
176
+ * The catch clause a framework-reaching call landed in swallows when its body
177
+ * contains no `throw` within the window: the refusal is absorbed and the hook
178
+ * carries on. Returns false (handled correctly) when the catch propagates the
179
+ * refusal, and true only for the swallowed shape.
180
+ */
181
+ function catchSwallows(lines: string[], catchIdx: number): boolean {
182
+ const end = Math.min(catchIdx + SWALLOW_CATCH_BODY_LINES, lines.length - 1);
183
+ for (let k = catchIdx + 1; k <= end; k++) {
184
+ if (THROW_RE.test(lines[k])) return false;
185
+ if (k > catchIdx + 1 && CATCH_CLOSE_RE.test(lines[k])) break;
186
+ }
187
+ return true;
188
+ }
189
+
100
190
  /** A `runAppCommand(` / `runAppCommandWithSecret(` CALL — not the import. */
101
191
  const RAW_EXEC_CALL = /\brunAppCommand(?:WithSecret)?\s*\(/;
102
192
 
@@ -131,6 +221,35 @@ export function scanModuleScriptSource(file: string, source: string): ScanViolat
131
221
  }
132
222
  });
133
223
 
224
+ // The swallowed-refusal pass: windowed, like the escape-hatch check above —
225
+ // a catch clause this far below a framework-reaching call is the call's
226
+ // catch. Reported on the CALL line, because that is the site an author fixes.
227
+ lines.forEach((text, i) => {
228
+ if (!FRAMEWORK_CALL_RE.test(text)) return;
229
+ const last = Math.min(i + SWALLOW_LOOKAHEAD_LINES, lines.length - 1);
230
+ for (let j = i + 1; j <= last; j++) {
231
+ if (CATCH_CLAUSE_RE.test(lines[j])) {
232
+ if (catchSwallows(lines, j)) {
233
+ violations.push({
234
+ file,
235
+ line: i + 1,
236
+ rule: SWALLOWED_REFUSAL_RULE,
237
+ hint:
238
+ 'A refused framework call must fail, not continue with an answer it invented ' +
239
+ '(e2e-suite-recovery §4): a refusal and an absence are different facts. Let the ' +
240
+ 'error propagate, or read through a surface that distinguishes them — an injected ' +
241
+ 'typed-absent reader (openspec/changes/hook-owned-state) — rather than catching ' +
242
+ 'and substituting.',
243
+ });
244
+ }
245
+ break;
246
+ }
247
+ // A blank line or a closing brace at statement level ends the call's
248
+ // statement; a catch further down belongs to something else.
249
+ if (lines[j].trim() === '' || /^[)}]/.test(lines[j])) break;
250
+ }
251
+ });
252
+
134
253
  return violations;
135
254
  }
136
255
 
@@ -164,12 +283,25 @@ export function moduleScriptFiles(scriptsDir: string): string[] {
164
283
 
165
284
  /**
166
285
  * Scan a module directory (the one holding `manifest.yml`). Returns every
167
- * violation in its `scripts/`, with paths relative to `moduleDir`.
286
+ * violation in its `scripts/`, with paths relative to `moduleDir`, minus the
287
+ * jailed-CLI-spawn sites on the debt list. The module id is the directory's
288
+ * basename — the same assumption `packaging/build.ts`'s refusal message makes.
168
289
  */
169
290
  export function scanModuleDirectory(moduleDir: string): ScanViolation[] {
170
- return moduleScriptFiles(join(moduleDir, 'scripts')).flatMap((f) =>
171
- scanModuleScriptSource(relative(moduleDir, f), readFileSync(f, 'utf-8')),
172
- );
291
+ const moduleId = basename(moduleDir);
292
+ // One exemption set per rule, keyed the same way: a debt entry silences its
293
+ // own rule on its own file and nothing else.
294
+ const debtByRule: Record<string, Set<string>> = {
295
+ [JAILED_CLI_SPAWN_RULE]: new Set(
296
+ JAILED_CLI_SPAWN_DEBT.filter((e) => e.module === moduleId).map((e) => e.file),
297
+ ),
298
+ [SWALLOWED_REFUSAL_RULE]: new Set(
299
+ SWALLOWED_REFUSAL_DEBT.filter((e) => e.module === moduleId).map((e) => e.file),
300
+ ),
301
+ };
302
+ return moduleScriptFiles(join(moduleDir, 'scripts'))
303
+ .flatMap((f) => scanModuleScriptSource(relative(moduleDir, f), readFileSync(f, 'utf-8')))
304
+ .filter((v) => !(debtByRule[v.rule]?.has(v.file) ?? false));
173
305
  }
174
306
 
175
307
  /** Render violations for a test failure message or a refused publish. */
@@ -177,6 +309,144 @@ export function formatViolations(violations: ScanViolation[]): string {
177
309
  return violations.map((v) => ` ${v.file}:${v.line}\n → ${v.rule}. ${v.hint}`).join('\n');
178
310
  }
179
311
 
312
+ export interface PolicyDebtEntry {
313
+ /** Module directory name under `modules/`. */
314
+ module: string;
315
+ /** Script path relative to the module directory, e.g. `scripts/setup.ts`. */
316
+ file: string;
317
+ /** How many jailed-CLI spawns the file carries today. Pinned so a NEW spawn in an exempted file cannot hide behind the entry. */
318
+ matches: number;
319
+ /** Who converts this site, and into what. */
320
+ reason: string;
321
+ }
322
+
323
+ /**
324
+ * The known jailed-CLI spawns, exempted from the rule above until their
325
+ * conversion lands (hook-owned-state task 5.5; e2e-suite-recovery §3).
326
+ *
327
+ * These sites are tracked defects, not sanctioned forms: the jail kills every
328
+ * one of them the same way it killed celilo-mgmt's on_install (5e0e624d). The
329
+ * list exists so the rule can land RED-proof while the conversion — which
330
+ * needs the HookStore from hook-owned-state tasks 2 and 3 — is still at 0%.
331
+ * It is narrow on purpose (celilo#1014's lesson): one entry per file, each
332
+ * naming its owner, each pinning the exact number of spawns so a new call
333
+ * site cannot hide behind an existing entry.
334
+ *
335
+ * The debt test in `no-hand-built-ssh.test.ts` asserts every entry still
336
+ * matches its recorded count. When a conversion lands the count drops to zero,
337
+ * the test goes red, and the entry must be deleted in the same change.
338
+ */
339
+ export const JAILED_CLI_SPAWN_DEBT: readonly PolicyDebtEntry[] = [
340
+ {
341
+ module: 'celilo-mgmt',
342
+ file: 'scripts/on_backup.ts',
343
+ matches: 1,
344
+ reason:
345
+ 'hook-owned-state 5.5 / survey class G: machine list becomes a machine-pool read capability',
346
+ },
347
+ {
348
+ module: 'caddy-internal',
349
+ file: 'scripts/private-web-functions.ts',
350
+ matches: 1,
351
+ reason: 'hook-owned-state 5.5 (routes, capability-owned-tables Group B1)',
352
+ },
353
+ {
354
+ module: 'dnsmasq-dhcp',
355
+ file: 'scripts/dhcp-server-functions.ts',
356
+ matches: 1,
357
+ reason:
358
+ 'hook-owned-state 5.5 shape (self-config write); not in its 10-site table — reconcile there',
359
+ },
360
+ {
361
+ module: 'forgejo',
362
+ file: 'scripts/on-consumer-removed.ts',
363
+ matches: 1,
364
+ reason: 'hook-owned-state 5.5 (runner_registrations, Group B3)',
365
+ },
366
+ {
367
+ module: 'forgejo',
368
+ file: 'scripts/setup.ts',
369
+ matches: 1,
370
+ reason: 'hook-owned-state 5.5 (secret set)',
371
+ },
372
+ {
373
+ module: 'forgejo',
374
+ file: 'scripts/source-forge-functions.ts',
375
+ matches: 1,
376
+ reason:
377
+ 'hook-owned-state 5.5 shape (self-config write); not in its 10-site table — reconcile there',
378
+ },
379
+ {
380
+ module: 'generic-cpanel-hosting-provider',
381
+ file: 'scripts/on-consumer-removed.ts',
382
+ matches: 1,
383
+ reason: 'hook-owned-state 5.5 (publications, Group B4)',
384
+ },
385
+ {
386
+ module: 'generic-cpanel-hosting-provider',
387
+ file: 'scripts/publish-functions.ts',
388
+ matches: 1,
389
+ reason:
390
+ 'hook-owned-state 5.5 shape (self-config write); not in its 10-site table — reconcile there',
391
+ },
392
+ {
393
+ module: 'wireguard',
394
+ file: 'scripts/control-plane-vpn-functions.ts',
395
+ matches: 1,
396
+ reason:
397
+ 'hook-owned-state 5.5 shape (self-config write); not in its 10-site table — reconcile there',
398
+ },
399
+ {
400
+ module: 'wireguard',
401
+ file: 'scripts/health-check.ts',
402
+ matches: 1,
403
+ reason:
404
+ 'survey class C: system config get becomes an injected read; not in hook-owned-state 5.5 — reconcile there',
405
+ },
406
+ {
407
+ module: 'wireguard',
408
+ file: 'scripts/on_install.ts',
409
+ matches: 1,
410
+ reason:
411
+ 'hook-owned-state 5.5 (registered_peers, Group B2); THE five-suite blocker per the wave-0 census',
412
+ },
413
+ {
414
+ module: 'wireguard-manager',
415
+ file: 'scripts/setup.ts',
416
+ matches: 3,
417
+ reason:
418
+ 'hook-owned-state 5.5 (vpn_endpoint/vpn_server_public_key/client_pool, category 3; secret set)',
419
+ },
420
+ ];
421
+
422
+ /**
423
+ * The known swallowed refusals, exempted from the rule above until their
424
+ * conversion lands (hook-owned-state 5.5; e2e-suite-recovery §4). Same
425
+ * mechanics and same narrowness as the spawn debt above — one entry per file,
426
+ * each naming its owner, each pinning the exact number of swallows — with the
427
+ * same guarded rot: when a site converts, the count drops, the debt test goes
428
+ * red, and the entry must be deleted in the same change.
429
+ *
430
+ * Converted on 2026-09-07 by ce-pvii: knot-unbound-internal/scripts/on-install.ts
431
+ * dropped its last swallow (the runCelilo wrapper), so the rule fires on
432
+ * exactly these two sites and nothing else in the tree.
433
+ */
434
+ export const SWALLOWED_REFUSAL_DEBT: readonly PolicyDebtEntry[] = [
435
+ {
436
+ module: 'celilo-mgmt',
437
+ file: 'scripts/on_backup.ts',
438
+ matches: 1,
439
+ reason:
440
+ 'hook-owned-state 5.5 / survey class G: machine list becomes a typed-absent machine-pool read',
441
+ },
442
+ {
443
+ module: 'wireguard',
444
+ file: 'scripts/health-check.ts',
445
+ matches: 1,
446
+ reason: 'survey class C: system config get becomes an injected typed-absent read',
447
+ },
448
+ ];
449
+
180
450
  /**
181
451
  * The files inside `@celilo/capabilities` that implement the remote-exec
182
452
  * primitives themselves. `remote.ts` is the ONE sanctioned place the
@@ -12,15 +12,18 @@
12
12
  */
13
13
 
14
14
  import { describe, expect, test } from 'bun:test';
15
- import { existsSync, readdirSync, statSync } from 'node:fs';
15
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
16
16
  import { join, resolve } from 'node:path';
17
17
  import {
18
+ JAILED_CLI_SPAWN_DEBT,
19
+ JAILED_CLI_SPAWN_RULE,
18
20
  REMOTE_PRIMITIVE_FILES,
19
21
  capabilityPackageSourceFiles,
20
22
  formatViolations,
21
23
  moduleScriptFiles,
22
24
  scanCapabilityPackageSource,
23
25
  scanModuleDirectory,
26
+ scanModuleScriptSource,
24
27
  } from './module-script-scan';
25
28
 
26
29
  /** Walk up from this test to the repo root (the dir holding both modules/ and apps/). */
@@ -56,6 +59,36 @@ describe('recurrence gate: modules never hand-build SSH', () => {
56
59
  });
57
60
  });
58
61
 
62
+ describe('recurrence gate: the jailed-CLI-spawn debt list is honest', () => {
63
+ // The debt list in module-script-scan exempts real, tracked defects from the
64
+ // jailed-CLI-spawn rule so the rule can land green before hook-owned-state
65
+ // converts them. Three ways this list could silently rot, and this is the
66
+ // guard for each:
67
+ // a site is converted -> the file stops matching, the entry must go
68
+ // a NEW site appears -> the pinned count moves, the entry must be revisited
69
+ // a file is deleted -> the entry names a ghost and must go
70
+ test('every entry names an existing file carrying exactly its pinned spawn count', () => {
71
+ for (const entry of JAILED_CLI_SPAWN_DEBT) {
72
+ const f = join(repoRoot(), 'modules', entry.module, entry.file);
73
+ expect(
74
+ existsSync(f),
75
+ `${entry.module}/${entry.file} no longer exists — delete its debt entry`,
76
+ ).toBe(true);
77
+ const hits = scanModuleScriptSource(entry.file, readFileSync(f, 'utf-8')).filter(
78
+ (v) => v.rule === JAILED_CLI_SPAWN_RULE,
79
+ );
80
+ expect(
81
+ hits.length,
82
+ `${entry.module}/${entry.file} carries ${hits.length} jailed-CLI spawns, the debt entry pins ${entry.matches} (${entry.reason}) — convert or update the entry`,
83
+ ).toBe(entry.matches);
84
+ }
85
+ });
86
+
87
+ test('the debt list is non-trivial (the guard actually reached the entries)', () => {
88
+ expect(JAILED_CLI_SPAWN_DEBT.length).toBeGreaterThan(5);
89
+ });
90
+ });
91
+
59
92
  describe('recurrence gate: @celilo/capabilities itself never hand-builds SSH', () => {
60
93
  // The workspace source — where the package is authored, and the only copy a
61
94
  // commit in this repo can fix. Each module's bundled copy is an npm snapshot
@@ -0,0 +1,265 @@
1
+ /**
2
+ * Recurrence gate for openspec/changes/e2e-suite-recovery §4: **a hook whose
3
+ * framework call is refused fails, rather than continuing with an answer it
4
+ * invented** (spec delta `specs/module-lifecycle/spec.md`, scenario "The
5
+ * pattern cannot be reintroduced").
6
+ *
7
+ * The rule itself lives in `./module-script-scan`, because this is not the only
8
+ * place it runs — `.netapp` packaging applies the same scan at publish time.
9
+ * One definition, two callers.
10
+ *
11
+ * The two sites this gate exists for (e2e-suite-recovery tasks 4.1/4.2, all
12
+ * still in the tree on the swallow debt list; a third, knot-unbound-internal
13
+ * scripts/on-install.ts:69, converted on 2026-09-07 by ce-pvii and deleted
14
+ * from both debt lists in the same change):
15
+ * modules/celilo-mgmt/scripts/on_backup.ts:52 catch { return [] }
16
+ * modules/wireguard/scripts/health-check.ts:73 catch { return null }
17
+ */
18
+
19
+ import { describe, expect, test } from 'bun:test';
20
+ import {
21
+ existsSync,
22
+ mkdirSync,
23
+ readFileSync,
24
+ readdirSync,
25
+ rmSync,
26
+ statSync,
27
+ writeFileSync,
28
+ } from 'node:fs';
29
+ import { join, resolve } from 'node:path';
30
+ import {
31
+ SWALLOWED_REFUSAL_DEBT,
32
+ SWALLOWED_REFUSAL_RULE,
33
+ formatViolations,
34
+ moduleScriptFiles,
35
+ scanModuleDirectory,
36
+ scanModuleScriptSource,
37
+ } from './module-script-scan';
38
+
39
+ /** Walk up from this test to the repo root (the dir holding both modules/ and apps/). */
40
+ function repoRoot(): string {
41
+ let dir = import.meta.dir;
42
+ for (let i = 0; i < 8; i++) {
43
+ if (existsSync(join(dir, 'modules')) && existsSync(join(dir, 'apps'))) return dir;
44
+ dir = resolve(dir, '..');
45
+ }
46
+ throw new Error('could not locate repo root (no ancestor with modules/ + apps/)');
47
+ }
48
+
49
+ function moduleDirs(): string[] {
50
+ const modulesRoot = join(repoRoot(), 'modules');
51
+ return readdirSync(modulesRoot)
52
+ .map((m) => join(modulesRoot, m))
53
+ .filter((d) => statSync(d).isDirectory() && existsSync(join(d, 'scripts')));
54
+ }
55
+
56
+ /** Swallow-rule violations only — the other rules are different gates with their own tests. */
57
+ function swallowRule(violations: ReturnType<typeof scanModuleScriptSource>) {
58
+ return violations.filter((v) => v.rule === SWALLOWED_REFUSAL_RULE);
59
+ }
60
+
61
+ describe('recurrence gate: a hook does not swallow a refused framework call', () => {
62
+ describe('the shapes it must reject', () => {
63
+ test('catch returning a literal below a spawned framework call (on_backup shape)', () => {
64
+ const source = [
65
+ 'function snapshot(): unknown[] {',
66
+ ' try {',
67
+ " const stdout = execSync('celilo machine list --json', { encoding: 'utf-8' });",
68
+ ' return JSON.parse(stdout);',
69
+ ' } catch {',
70
+ ' // older celilo, or no machines yet',
71
+ ' return [];',
72
+ ' }',
73
+ '}',
74
+ ].join('\n');
75
+ const hits = swallowRule(scanModuleScriptSource('scripts/x.ts', source));
76
+ expect(hits.length, `expected 1, got:\n${formatViolations(hits)}`).toBe(1);
77
+ expect(hits[0].line).toBe(3); // the framework-reaching call, not the catch
78
+ });
79
+
80
+ test('comment-only catch below an injected CLI-runner call (on_install shape)', () => {
81
+ const source = [
82
+ 'function repoint(ip: string, runCelilo: (args: string[]) => string): void {',
83
+ ' let prior: string | undefined;',
84
+ ' try {',
85
+ " prior = parse(runCelilo(['system', 'config', 'get', 'dns.primary']));",
86
+ ' } catch {',
87
+ ' // dns.primary not set yet — nothing to demote.',
88
+ ' }',
89
+ ' runCelilo(["system", "apply-config", `dns.primary=${ip}`]);',
90
+ '}',
91
+ ].join('\n');
92
+ const hits = swallowRule(scanModuleScriptSource('scripts/x.ts', source));
93
+ expect(hits.length, `expected 1, got:\n${formatViolations(hits)}`).toBe(1);
94
+ expect(hits[0].line).toBe(4);
95
+ });
96
+
97
+ test('catch returning null below execFileSync (health-check shape)', () => {
98
+ const source = [
99
+ 'function read(key: string): string | null {',
100
+ ' try {',
101
+ " const out = execFileSync('celilo', ['system', 'config', 'get', key], { encoding: 'utf-8' });",
102
+ ' return parse(key, out);',
103
+ ' } catch {',
104
+ ' return null;',
105
+ ' }',
106
+ '}',
107
+ ].join('\n');
108
+ const hits = swallowRule(scanModuleScriptSource('scripts/x.ts', source));
109
+ expect(hits.length, `expected 1, got:\n${formatViolations(hits)}`).toBe(1);
110
+ expect(hits[0].line).toBe(3);
111
+ });
112
+
113
+ test('log-and-continue catch below a spawned framework call', () => {
114
+ const source = [
115
+ 'try {',
116
+ " execSync('celilo system apply-config dns.primary=10.0.0.1');",
117
+ '} catch (err) {',
118
+ ' logger.warn(`repoint failed: ${err}`);',
119
+ '}',
120
+ 'logger.success("dns.primary repointed");',
121
+ ].join('\n');
122
+ const hits = swallowRule(scanModuleScriptSource('scripts/x.ts', source));
123
+ expect(hits.length, `expected 1, got:\n${formatViolations(hits)}`).toBe(1);
124
+ });
125
+ });
126
+
127
+ describe('the shapes it must accept', () => {
128
+ test('a typed-absent reader: no catch between the hook and the answer', () => {
129
+ const source = [
130
+ 'export async function healthCheck(deps: HealthDeps): Promise<Output> {',
131
+ ' const { readConfig } = deps;',
132
+ ' const value = readConfig("server_public_key");',
133
+ ' if (!value.present) return { status: "absent", key: "server_public_key" };',
134
+ ' return { status: "ok", key: value.data };',
135
+ '}',
136
+ ].join('\n');
137
+ expect(swallowRule(scanModuleScriptSource('scripts/x.ts', source))).toEqual([]);
138
+ });
139
+
140
+ test('a catch that rethrows: the refusal propagates', () => {
141
+ const source = [
142
+ 'try {',
143
+ " execSync('celilo system apply-config dns.primary=10.0.0.1');",
144
+ '} catch (err) {',
145
+ ' throw new Error(`dns.primary repoint refused: ${String(err)}`);',
146
+ '}',
147
+ ].join('\n');
148
+ expect(swallowRule(scanModuleScriptSource('scripts/x.ts', source))).toEqual([]);
149
+ });
150
+
151
+ test('a catch below work that never reaches the framework', () => {
152
+ const source = [
153
+ 'function parse(raw: string): unknown[] {',
154
+ ' try {',
155
+ ' const parsed: unknown = JSON.parse(raw);',
156
+ ' return Array.isArray(parsed) ? parsed : [];',
157
+ ' } catch {',
158
+ ' return [];',
159
+ ' }',
160
+ '}',
161
+ ].join('\n');
162
+ expect(swallowRule(scanModuleScriptSource('scripts/x.ts', source))).toEqual([]);
163
+ });
164
+ });
165
+
166
+ test('reach: the scan walks every scripts/ directory and flags every planted violation', () => {
167
+ // Mirror of the real layout (modules/<m>/scripts/**), planted per the
168
+ // reach rule: a violating hook in EVERY directory the scan should reach —
169
+ // the module scripts root and a nested subdirectory — plus a clean module
170
+ // the scan must come back empty on.
171
+ const root = join(repoRoot(), '.tmp-swallow-reach-fixture');
172
+ rmSync(root, { recursive: true, force: true });
173
+ const violating = (name: string) =>
174
+ [
175
+ `// ${name}`,
176
+ 'export function run() {',
177
+ ' try {',
178
+ " execSync('celilo system config get dns.primary');",
179
+ ' } catch {',
180
+ ' return null;',
181
+ ' }',
182
+ '}',
183
+ ].join('\n');
184
+ try {
185
+ const planted = [
186
+ 'modules/alpha/scripts/one.ts',
187
+ 'modules/alpha/scripts/nested/two.ts',
188
+ 'modules/beta/scripts/three.ts',
189
+ ];
190
+ for (const p of planted) {
191
+ const f = join(root, p);
192
+ mkdirSync(resolve(f, '..'), { recursive: true });
193
+ writeFileSync(f, violating(p));
194
+ }
195
+ const cleanDir = join(root, 'modules/clean/scripts');
196
+ mkdirSync(cleanDir, { recursive: true });
197
+ writeFileSync(
198
+ join(cleanDir, 'clean.ts'),
199
+ 'export function run(readConfig: (k: string) => { present: boolean }) {\n return readConfig("k");\n}\n',
200
+ );
201
+
202
+ // Per-module expectation, in the module-relative paths the scan
203
+ // reports: every planted file came back exactly once, and the clean
204
+ // module contributed nothing.
205
+ const flagged = (m: string) =>
206
+ new Set(swallowRule(scanModuleDirectory(join(root, 'modules', m))).map((v) => v.file));
207
+ expect([...flagged('alpha')].sort()).toEqual(['scripts/nested/two.ts', 'scripts/one.ts']);
208
+ expect([...flagged('beta')]).toEqual(['scripts/three.ts']);
209
+ expect([...flagged('clean')]).toEqual([]);
210
+ } finally {
211
+ rmSync(root, { recursive: true, force: true });
212
+ }
213
+ });
214
+
215
+ test('today’s tree: the remaining known sites are flagged, and nothing else is', () => {
216
+ // Raw scan, deliberately NOT scanModuleDirectory: the debt list below
217
+ // exempts these same files so the merged gate stays green while
218
+ // hook-owned-state converts them, and this assertion has to outlive that
219
+ // exemption to prove the rule still reaches them.
220
+ const violations = moduleDirs().flatMap((d) =>
221
+ swallowRule(
222
+ moduleScriptFiles(join(d, 'scripts')).flatMap((f) =>
223
+ scanModuleScriptSource(f, readFileSync(f, 'utf-8')),
224
+ ),
225
+ ),
226
+ );
227
+ const sites = violations.map((v) => v.file.replace(`${repoRoot()}/`, '')).sort();
228
+ // Pinned to the exact sites from e2e-suite-recovery task 4.2, as measured
229
+ // on 2026-09-07 at on_backup.ts:52 and health-check.ts:73 (a third,
230
+ // on-install.ts:69, converted by ce-pvii the same day). A converted site
231
+ // drops out and the debt entry must go; a NEW site appearing here is the
232
+ // next instance this scan exists to stop.
233
+ expect(sites).toEqual([
234
+ 'modules/celilo-mgmt/scripts/on_backup.ts',
235
+ 'modules/wireguard/scripts/health-check.ts',
236
+ ]);
237
+ });
238
+ });
239
+
240
+ describe('recurrence gate: the swallow debt list is honest', () => {
241
+ // Same three ways this list could silently rot as the jailed-CLI-spawn debt
242
+ // guard, same guard for each.
243
+ test('every entry names an existing file carrying exactly its pinned swallow count', () => {
244
+ for (const entry of SWALLOWED_REFUSAL_DEBT) {
245
+ const f = join(repoRoot(), 'modules', entry.module, entry.file);
246
+ expect(
247
+ existsSync(f),
248
+ `${entry.module}/${entry.file} no longer exists — delete its debt entry`,
249
+ ).toBe(true);
250
+ const hits = scanModuleScriptSource(entry.file, readFileSync(f, 'utf-8')).filter(
251
+ (v) => v.rule === SWALLOWED_REFUSAL_RULE,
252
+ );
253
+ expect(
254
+ hits.length,
255
+ `${entry.module}/${entry.file} carries ${hits.length} swallowed refusals, the debt entry pins ${entry.matches} (${entry.reason}) — convert or update the entry`,
256
+ ).toBe(entry.matches);
257
+ }
258
+ });
259
+
260
+ test('the debt list is non-trivial (the guard actually reached the entries)', () => {
261
+ // The list held three entries until ce-pvii converted the on-install.ts
262
+ // entry (2026-09-07); two remain, so the guard still proves engagement.
263
+ expect(SWALLOWED_REFUSAL_DEBT.length).toBeGreaterThan(1);
264
+ });
265
+ });
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Recurrence gate for celilo#1235: **services never shell out to `tar`; they
3
+ * use the `tar` package.**
4
+ *
5
+ * `tar@^7` is a declared dependency of @celilo/cli, but backup/restore built
6
+ * tar command lines and handed them to execSync. That made `tar` an undeclared
7
+ * runtime dependency on a host binary: Essential on Debian, so it never bit,
8
+ * but a container base image is where "always there" stops being true. The
9
+ * services now use the library (same shape the hook boundary is moving to via
10
+ * pack_directory), and this file keeps them there.
11
+ *
12
+ * Scoped to apps/celilo/src/services (the surface celilo#1235 covers). Test
13
+ * files are excluded — they legitimately drive the system tar to cross-check
14
+ * archives written by the library.
15
+ */
16
+
17
+ import { describe, expect, test } from 'bun:test';
18
+ import { readFileSync, readdirSync, statSync } from 'node:fs';
19
+ import { join } from 'node:path';
20
+
21
+ function serviceSourceFiles(): string[] {
22
+ const dir = join(import.meta.dir, '../services');
23
+ const walk = (d: string): string[] =>
24
+ readdirSync(d).flatMap((name) => {
25
+ const path = join(d, name);
26
+ if (statSync(path).isDirectory()) return walk(path);
27
+ return name.endsWith('.ts') && !name.endsWith('.test.ts') ? [path] : [];
28
+ });
29
+ return walk(dir);
30
+ }
31
+
32
+ describe('recurrence gate: services never shell out to tar', () => {
33
+ test('scans a non-trivial set of service sources (sanity — the scan actually ran)', () => {
34
+ expect(serviceSourceFiles().length).toBeGreaterThan(10);
35
+ });
36
+
37
+ test('no service source passes a "tar ..." string to execSync', () => {
38
+ const offenders = serviceSourceFiles().filter((path) =>
39
+ /execSync\(\s*[`'"]tar /.test(readFileSync(path, 'utf-8')),
40
+ );
41
+ expect(offenders).toEqual([]);
42
+ });
43
+ });