@celilo/cli 2.0.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 (103) hide show
  1. package/CELILO_CORE_MODULES.md +1 -1
  2. package/CELILO_SUBSYSTEMS.md +1 -0
  3. package/drizzle/0031_module_config_source.sql +20 -0
  4. package/drizzle/meta/_journal.json +8 -1
  5. package/package.json +2 -2
  6. package/schemas/system_config.json +2 -1
  7. package/src/capabilities/public-web-publish.test.ts +61 -0
  8. package/src/cli/commands/firewall-interface-list.test.ts +156 -7
  9. package/src/cli/commands/firewall-interface-list.ts +73 -7
  10. package/src/cli/commands/machine-add.ts +12 -55
  11. package/src/cli/commands/module-config.test.ts +20 -1
  12. package/src/cli/commands/module-import.ts +1 -1
  13. package/src/cli/commands/module-update.test.ts +82 -0
  14. package/src/cli/commands/module-update.ts +14 -4
  15. package/src/cli/commands/monitor.ts +2 -10
  16. package/src/cli/commands/restore.ts +16 -6
  17. package/src/cli/generate-zsh-completion.ts +1 -1
  18. package/src/cli/index.ts +4 -3
  19. package/src/cli/restore-migration-failure.test.ts +159 -0
  20. package/src/db/client.ts +5 -0
  21. package/src/db/migrate.test.ts +61 -135
  22. package/src/db/migrate.ts +7 -2
  23. package/src/db/schema.ts +10 -0
  24. package/src/hooks/broker.test.ts +106 -2
  25. package/src/hooks/broker.ts +91 -1
  26. package/src/hooks/capability-loader-firewall.test.ts +37 -0
  27. package/src/hooks/capability-loader.ts +16 -3
  28. package/src/hooks/define-hook.test.ts +4 -3
  29. package/src/hooks/executor.test.ts +19 -18
  30. package/src/hooks/executor.ts +88 -11
  31. package/src/hooks/hook-jail-toolchain-reach.test.ts +79 -29
  32. package/src/hooks/hook-jail-unreachability.test.ts +55 -29
  33. package/src/hooks/hook-protocol.ts +46 -1
  34. package/src/hooks/hook-runner.ts +36 -0
  35. package/src/hooks/hook-store-proxy.test.ts +109 -0
  36. package/src/hooks/hook-store-proxy.ts +85 -0
  37. package/src/hooks/hook-store.test.ts +162 -0
  38. package/src/hooks/hook-store.ts +290 -0
  39. package/src/hooks/hook-timeout.test.ts +3 -2
  40. package/src/hooks/hook-trespass.test.ts +94 -14
  41. package/src/hooks/jail.test.ts +1 -1
  42. package/src/hooks/jail.ts +194 -32
  43. package/src/hooks/mount-set.test.ts +296 -1
  44. package/src/hooks/mount-set.ts +216 -13
  45. package/src/hooks/run-named-hook.ts +2 -0
  46. package/src/hooks/test-fixtures/jail-probe-hook.ts +1 -1
  47. package/src/hooks/test-fixtures/on-restore-staging-hook.ts +26 -0
  48. package/src/hooks/test-fixtures/store-backed.ts +47 -0
  49. package/src/hooks/test-fixtures/store-writing-hook.ts +63 -0
  50. package/src/hooks/unjailed-lint.test.ts +27 -8
  51. package/src/manifest/schema.ts +1 -0
  52. package/src/module/packaging/build.ts +70 -2
  53. package/src/module/web-root.ts +17 -1
  54. package/src/policy/fixture-capability-coverage.test.ts +322 -0
  55. package/src/policy/module-script-scan.test.ts +42 -1
  56. package/src/policy/module-script-scan.ts +275 -5
  57. package/src/policy/no-hand-built-ssh.test.ts +34 -1
  58. package/src/policy/no-swallowed-refusal.test.ts +265 -0
  59. package/src/policy/no-tar-shell-out-in-services.test.ts +43 -0
  60. package/src/registry/client.test.ts +149 -0
  61. package/src/registry/client.ts +203 -11
  62. package/src/services/alerting/coverage-source.test.ts +86 -0
  63. package/src/services/alerting/coverage-source.ts +11 -1
  64. package/src/services/alerting/format.test.ts +57 -0
  65. package/src/services/alerting/format.ts +24 -0
  66. package/src/services/alerting/run-monitor.ts +2 -2
  67. package/src/services/backup-create.ts +7 -7
  68. package/src/services/backup-envelope-roundtrip.test.ts +45 -2
  69. package/src/services/backup-restore.ts +8 -4
  70. package/src/services/bus-interview.ts +37 -14
  71. package/src/services/config-provenance.ts +4 -0
  72. package/src/services/control-plane-bootstrap.test.ts +297 -0
  73. package/src/services/control-plane-bootstrap.ts +223 -0
  74. package/src/services/control-plane-health.test.ts +66 -0
  75. package/src/services/control-plane-health.ts +67 -0
  76. package/src/services/deploy-preflight.ts +8 -2
  77. package/src/services/deploy-validation.test.ts +22 -0
  78. package/src/services/deploy-validation.ts +8 -0
  79. package/src/services/deployed-systems.ts +12 -0
  80. package/src/services/dns-discovery.test.ts +147 -0
  81. package/src/services/dns-discovery.ts +134 -0
  82. package/src/services/fleet-checks.ts +6 -2
  83. package/src/services/fleet-key.test.ts +66 -2
  84. package/src/services/fleet-key.ts +54 -0
  85. package/src/services/health-runner.ts +36 -3
  86. package/src/services/module-config.ts +20 -2
  87. package/src/services/module-deploy.dns-repoint.test.ts +187 -0
  88. package/src/services/module-deploy.ts +214 -1
  89. package/src/services/module-validator/git-hygiene.test.ts +122 -3
  90. package/src/services/module-validator/git-hygiene.ts +83 -14
  91. package/src/services/remote-access.test.ts +88 -4
  92. package/src/services/remote-access.ts +52 -1
  93. package/src/services/restore-from-file.test.ts +20 -0
  94. package/src/services/restore-from-file.ts +21 -6
  95. package/src/services/static-content-converge.test.ts +140 -2
  96. package/src/services/static-content-converge.ts +55 -8
  97. package/src/services/system-config-schema-types.ts +1 -1
  98. package/src/services/system-config-validator.test.ts +36 -0
  99. package/src/services/system-config-validator.ts +11 -0
  100. package/src/services/trusted-sources.test.ts +30 -0
  101. package/src/services/trusted-sources.ts +47 -10
  102. package/src/templates/generator.ts +9 -2
  103. package/src/variables/context.ts +16 -5
@@ -12,7 +12,11 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
12
12
  import { tmpdir } from 'node:os';
13
13
  import { join } from 'node:path';
14
14
  import { buildModule } from '../module/packaging/build';
15
- import { scanModuleDirectory, scanModuleScriptSource } from './module-script-scan';
15
+ import {
16
+ JAILED_CLI_SPAWN_RULE,
17
+ scanModuleDirectory,
18
+ scanModuleScriptSource,
19
+ } from './module-script-scan';
16
20
 
17
21
  const rules = (src: string) => scanModuleScriptSource('f.ts', src).map((v) => v.rule);
18
22
 
@@ -60,6 +64,43 @@ describe('module script scan — namespace tools (hygiene, not a boundary)', ()
60
64
  });
61
65
  });
62
66
 
67
+ describe('module script scan — jailed CLI spawn rule', () => {
68
+ // Each spawn spelling asserted separately. An alternation that loses a leg
69
+ // still passes a test that only exercises the first.
70
+ it.each([
71
+ [
72
+ "execFileSync('celilo', argv)",
73
+ "execFileSync('celilo', ['module', 'config', 'set', 'x', 'y']);",
74
+ ],
75
+ ['execFileSync("celilo", argv)', 'execFileSync("celilo", args);'],
76
+ ['execSync(`celilo …`)', 'execSync(`celilo module secret set x y`);'],
77
+ [
78
+ 'exec(`celilo …`) after a shell prefix',
79
+ 'exec(`cd /opt && sudo celilo system config get k`);',
80
+ ],
81
+ ["spawn('celilo', argv)", "const child = spawn('celilo', ['events', 'status']);"],
82
+ ])('catches %s', (_spelling, src) => {
83
+ expect(rules(src)).toContain(JAILED_CLI_SPAWN_RULE);
84
+ });
85
+
86
+ it('does not fire on operator prose that merely names the CLI', () => {
87
+ expect(rules('throw new Error("re-run `celilo module deploy x` to regenerate it");')).toEqual(
88
+ [],
89
+ );
90
+ });
91
+
92
+ // The `\w` after `celilo ` is what separates a CLI invocation from an
93
+ // argument that merely begins with the string, so the negative case has to
94
+ // be a hyphenated name, which would match a bare `\bcelilo\b`.
95
+ it('does not fire on a container argument named celilo-*', () => {
96
+ expect(rules('execSync(`docker exec celilo-mgr status`);')).toEqual([]);
97
+ });
98
+
99
+ it('does not fire on the celilo.db file name', () => {
100
+ expect(rules('execSync(`cp /var/lib/celilo/celilo.db /tmp/x`);')).toEqual([]);
101
+ });
102
+ });
103
+
63
104
  describe('module script scan — raw-exec escape hatch', () => {
64
105
  it('flags a runAppCommand call with no justification', () => {
65
106
  expect(rules('const r = runAppCommand(system, "rm -f /tmp/x", run);')).toContain(
@@ -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