@celilo/cli 2.2.0 → 2.3.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 (180) hide show
  1. package/CELILO_CORE_MODULES.md +1 -0
  2. package/CELILO_SUBSYSTEMS.md +1 -1
  3. package/README.md +1 -1
  4. package/drizzle/0032_module_jail_policies.sql +28 -0
  5. package/drizzle/0033_build_bus_hook_runs.sql +37 -0
  6. package/drizzle/meta/_journal.json +15 -1
  7. package/package.json +3 -3
  8. package/schemas/system_config.json +5 -0
  9. package/src/__integration__/container-services-cli.integration.test.ts +1 -1
  10. package/src/ansible/inventory.test.ts +2 -1
  11. package/src/api/sessions.test.ts +5 -4
  12. package/src/api-clients/proxmox.ts +10 -7
  13. package/src/cli/backup-rename.test.ts +5 -4
  14. package/src/cli/cli.test.ts +3 -2
  15. package/src/cli/commands/console-get-chain.test.ts +2 -1
  16. package/src/cli/commands/events.test.ts +2 -2
  17. package/src/cli/commands/firewall-interface-list.test.ts +4 -3
  18. package/src/cli/commands/machine-list.test.ts +57 -0
  19. package/src/cli/commands/machine-list.ts +35 -4
  20. package/src/cli/commands/module-config.test.ts +2 -1
  21. package/src/cli/commands/module-deploy.ts +8 -2
  22. package/src/cli/commands/module-generate.test.ts +53 -0
  23. package/src/cli/commands/module-generate.ts +31 -26
  24. package/src/cli/commands/module-health.ts +1 -0
  25. package/src/cli/commands/module-import-aspect.test.ts +2 -1
  26. package/src/cli/commands/module-import-registry.test.ts +3 -2
  27. package/src/cli/commands/module-jail.test.ts +242 -0
  28. package/src/cli/commands/module-jail.ts +227 -0
  29. package/src/cli/commands/module-list-jail.test.ts +136 -0
  30. package/src/cli/commands/module-list.ts +30 -3
  31. package/src/cli/commands/module-operations.test.ts +2 -1
  32. package/src/cli/commands/module-publish.test.ts +11 -18
  33. package/src/cli/commands/module-update.test.ts +28 -12
  34. package/src/cli/commands/module-update.ts +4 -1
  35. package/src/cli/commands/module-upgrade.test.ts +130 -1
  36. package/src/cli/commands/module-upgrade.ts +77 -3
  37. package/src/cli/commands/module-verify.test.ts +3 -4
  38. package/src/cli/commands/module-verify.ts +0 -1
  39. package/src/cli/commands/notify-config.test.ts +5 -3
  40. package/src/cli/commands/publish/execute.ts +4 -1
  41. package/src/cli/commands/publish/index.ts +11 -1
  42. package/src/cli/commands/publish/module-registry.test.ts +24 -1
  43. package/src/cli/commands/publish/module-registry.ts +23 -4
  44. package/src/cli/commands/publish/plan.ts +1 -1
  45. package/src/cli/commands/publish/types.ts +7 -0
  46. package/src/cli/commands/registry-owner.test.ts +3 -2
  47. package/src/cli/commands/registry-token.test.ts +3 -2
  48. package/src/cli/commands/system-apply-config-equivalence.test.ts +5 -7
  49. package/src/cli/commands/system-audit.ts +20 -6
  50. package/src/cli/commands/system-config.test.ts +148 -0
  51. package/src/cli/commands/system-config.ts +26 -1
  52. package/src/cli/commands/system-doctor-remediation-gate.test.ts +295 -0
  53. package/src/cli/commands/system-doctor.test.ts +143 -6
  54. package/src/cli/commands/system-doctor.ts +142 -25
  55. package/src/cli/commands/system-init-deprecation.test.ts +7 -4
  56. package/src/cli/commands/system-migrate.test.ts +2 -1
  57. package/src/cli/commands/system-update.ts +5 -0
  58. package/src/cli/completion.ts +8 -2
  59. package/src/cli/flag-surface-gate.test.ts +279 -0
  60. package/src/cli/index.ts +70 -3
  61. package/src/cli/parser.test.ts +37 -1
  62. package/src/cli/restore-command.test.ts +5 -4
  63. package/src/cli/restore-migration-failure.test.ts +4 -3
  64. package/src/cli/tui/audit-state.ts +2 -0
  65. package/src/config/paths.test.ts +22 -22
  66. package/src/db/client.test.ts +46 -1
  67. package/src/db/client.ts +26 -0
  68. package/src/db/migrate.test.ts +2 -1
  69. package/src/db/migrate.ts +16 -16
  70. package/src/db/schema.ts +63 -0
  71. package/src/hooks/capability-loader-firewall.test.ts +9 -1
  72. package/src/hooks/capability-loader.test.ts +78 -0
  73. package/src/hooks/capability-loader.ts +45 -2
  74. package/src/hooks/executor.test.ts +161 -1
  75. package/src/hooks/executor.ts +87 -21
  76. package/src/hooks/hook-jail-unreachability.test.ts +17 -18
  77. package/src/hooks/hook-store.test.ts +6 -0
  78. package/src/hooks/hook-trespass.test.ts +26 -9
  79. package/src/hooks/jail.test.ts +152 -5
  80. package/src/hooks/jail.ts +93 -4
  81. package/src/manifest/contracts/v1.ts +13 -0
  82. package/src/module/packaging/audit.ts +9 -26
  83. package/src/module/packaging/build-paths.test.ts +127 -0
  84. package/src/module/packaging/build-paths.ts +175 -0
  85. package/src/module/packaging/build.test.ts +71 -1
  86. package/src/module/packaging/build.ts +60 -0
  87. package/src/module/packaging/extract.ts +1 -5
  88. package/src/policy/module-script-scan.ts +51 -107
  89. package/src/policy/no-hand-built-ssh.test.ts +5 -1
  90. package/src/policy/no-swallowed-refusal.test.ts +14 -15
  91. package/src/registry/client.test.ts +2 -2
  92. package/src/secrets/storage.test.ts +1 -1
  93. package/src/services/alerting/ack.test.ts +2 -1
  94. package/src/services/alerting/cadence-migration.test.ts +3 -2
  95. package/src/services/alerting/coverage-source.test.ts +2 -1
  96. package/src/services/alerting/deferral.test.ts +2 -1
  97. package/src/services/alerting/delivery-loop.test.ts +2 -1
  98. package/src/services/alerting/deploy-hooks.test.ts +2 -1
  99. package/src/services/alerting/inbound-poller.test.ts +2 -1
  100. package/src/services/alerting/inbound.test.ts +2 -1
  101. package/src/services/alerting/keys.test.ts +4 -0
  102. package/src/services/alerting/keys.ts +12 -2
  103. package/src/services/alerting/notification-responder.test.ts +2 -1
  104. package/src/services/alerting/run-monitor.test.ts +2 -1
  105. package/src/services/alerting/store.test.ts +2 -1
  106. package/src/services/alerting/sweep-runner.test.ts +2 -1
  107. package/src/services/alerting/tokens.test.ts +2 -1
  108. package/src/services/aspect-approvals.test.ts +2 -1
  109. package/src/services/aspect-reconcile.test.ts +4 -3
  110. package/src/services/aspect-runner.test.ts +2 -1
  111. package/src/services/audit/health.test.ts +97 -2
  112. package/src/services/audit/health.ts +64 -2
  113. package/src/services/audit/index.test.ts +22 -0
  114. package/src/services/audit/index.ts +8 -2
  115. package/src/services/audit/jail-exemptions.test.ts +42 -0
  116. package/src/services/audit/jail-exemptions.ts +44 -0
  117. package/src/services/audit/module-integrity.test.ts +23 -22
  118. package/src/services/audit/module-integrity.ts +7 -16
  119. package/src/services/audit/types.ts +2 -1
  120. package/src/services/backup-age-agreement.test.ts +2 -1
  121. package/src/services/backup-create.ts +9 -0
  122. package/src/services/backup-envelope-roundtrip.test.ts +3 -2
  123. package/src/services/backup-in-flight-refusal.test.ts +3 -2
  124. package/src/services/build-bus/hook-dispatch-mgmt.test.ts +48 -0
  125. package/src/services/build-bus/hook-dispatcher.ts +49 -1
  126. package/src/services/bus-ensure-flow.test.ts +3 -2
  127. package/src/services/bus-interview-park.test.ts +4 -3
  128. package/src/services/bus-interview.test.ts +2 -2
  129. package/src/services/bus-secret-flow.test.ts +3 -2
  130. package/src/services/capability-table-rows.test.ts +2 -1
  131. package/src/services/celilo-events.test.ts +1 -1
  132. package/src/services/celilo-mgmt-hooks.test.ts +23 -5
  133. package/src/services/consumer-cleanup.test.ts +3 -2
  134. package/src/services/container-service.test.ts +3 -2
  135. package/src/services/control-plane-bootstrap.test.ts +2 -1
  136. package/src/services/cross-module-read.test.ts +4 -3
  137. package/src/services/deploy-preflight.ts +7 -0
  138. package/src/services/deploy-validation.test.ts +3 -2
  139. package/src/services/deployed-systems.ts +1 -1
  140. package/src/services/dns-internal-records.test.ts +3 -2
  141. package/src/services/dns-provider-backfill.test.ts +2 -1
  142. package/src/services/dns-registrations.test.ts +2 -1
  143. package/src/services/ensure-interview.test.ts +3 -2
  144. package/src/services/fleet-checks.test.ts +126 -6
  145. package/src/services/fleet-checks.ts +176 -3
  146. package/src/services/fleet-key.test.ts +3 -2
  147. package/src/services/health-runner.test.ts +87 -2
  148. package/src/services/health-runner.ts +40 -11
  149. package/src/services/infrastructure-selector.test.ts +3 -2
  150. package/src/services/infrastructure-variable-resolver.test.ts +2 -1
  151. package/src/services/jail-exemptions.test.ts +125 -0
  152. package/src/services/jail-exemptions.ts +81 -0
  153. package/src/services/machine-pool.test.ts +3 -2
  154. package/src/services/module-config.test.ts +2 -1
  155. package/src/services/module-deploy.dns-repoint.test.ts +2 -1
  156. package/src/services/module-deploy.ts +48 -24
  157. package/src/services/module-operations.test.ts +2 -1
  158. package/src/services/module-subscriptions.test.ts +4 -3
  159. package/src/services/network-discovery.test.ts +64 -1
  160. package/src/services/network-discovery.ts +30 -5
  161. package/src/services/port-forwards.test.ts +2 -1
  162. package/src/services/programmatic-responder.aspect.test.ts +2 -1
  163. package/src/services/proxmox-reconcile.test.ts +2 -1
  164. package/src/services/responder-probe.test.ts +1 -1
  165. package/src/services/restore-from-file.test.ts +7 -6
  166. package/src/services/restore-preflight.test.ts +3 -2
  167. package/src/services/secret-schema-loader.test.ts +2 -1
  168. package/src/services/ssh-key-manager.test.ts +4 -3
  169. package/src/services/static-content-converge.test.ts +5 -4
  170. package/src/services/static-content-converge.ts +33 -10
  171. package/src/services/system-state-stage.test.ts +4 -3
  172. package/src/services/trusted-sources.test.ts +3 -2
  173. package/src/services/update/orchestrator.test.ts +2 -0
  174. package/src/services/zone-detector.test.ts +2 -1
  175. package/src/test-utils/bus-responder.ts +6 -4
  176. package/src/test-utils/db-path.ts +25 -0
  177. package/src/test-utils/integration.ts +7 -0
  178. package/src/test-utils/module-fixtures.ts +5 -6
  179. package/src/module/packaging/generated-plane.test.ts +0 -79
  180. package/src/module/packaging/generated-plane.ts +0 -134
@@ -0,0 +1,175 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { resolve } from 'node:path';
3
+
4
+ /**
5
+ * Publish-time gate on a module build command's filesystem references.
6
+ *
7
+ * A `build:` block runs when the module is packaged (publish/package/import),
8
+ * inside a staged copy of the module, with `CELILO_MODULE_SOURCE_DIR` pointing
9
+ * back at the authored source tree. A `cd` in that command is the build's
10
+ * claim about where its inputs live; a target that does not resolve there
11
+ * means the module ships inputs it does not carry, which is how celilo-registry
12
+ * was unbuildable on every real install for four months without anyone finding
13
+ * out (celilo#1307). The packager refuses up front and names the path.
14
+ *
15
+ * The gate does not interpret shell. It finds `cd` commands lexically, knows
16
+ * the two variables the packager supplies, and probes what those resolve to.
17
+ * A target it cannot resolve (an untracked shell variable, a command
18
+ * substitution, a glob) is SKIPPED, not failed: guessing would reject working
19
+ * modules, and a guard that rejects working modules is worse than none.
20
+ */
21
+
22
+ export interface BuildPathViolation {
23
+ /** The path exactly as the build command wrote it, before substitution. */
24
+ rawPath: string;
25
+ /** The absolute path the packaging runtime would resolve it to. */
26
+ resolvedPath: string;
27
+ }
28
+
29
+ export interface ResolveBuildCommandPathsOptions {
30
+ /** What `$CELILO_MODULE_SOURCE_DIR` resolves to when the command runs. */
31
+ moduleSourceDir: string;
32
+ /** The staged tree the command runs in (its initial cwd, and `$STAGE`). */
33
+ buildDir: string;
34
+ /** Injectable existence probe; defaults to existsSync. */
35
+ exists?: (path: string) => boolean;
36
+ }
37
+
38
+ /**
39
+ * Extract the argument of every unquoted `cd` command in the string, with the
40
+ * quoting preserved so later substitution can strip it.
41
+ *
42
+ * A `cd` counts only at a command position: string start, or after `&&`, `||`,
43
+ * `;`, `|`, `(`, or a newline, ignoring whitespace. Quoted spans (single and
44
+ * double) are skipped whole, so a `cd` inside a string is not mistaken for a
45
+ * command.
46
+ */
47
+ export function findCdTargets(command: string): string[] {
48
+ const targets: string[] = [];
49
+ let atCommandStart = true;
50
+ let i = 0;
51
+ while (i < command.length) {
52
+ const ch = command[i];
53
+ if (ch === "'") {
54
+ const end = command.indexOf("'", i + 1);
55
+ i = end === -1 ? command.length : end + 1;
56
+ atCommandStart = false;
57
+ continue;
58
+ }
59
+ if (ch === '"') {
60
+ const end = command.indexOf('"', i + 1);
61
+ i = end === -1 ? command.length : end + 1;
62
+ atCommandStart = false;
63
+ continue;
64
+ }
65
+ if (ch === '\\' && i + 1 < command.length) {
66
+ i += 2;
67
+ atCommandStart = false;
68
+ continue;
69
+ }
70
+ if (ch === '&' || ch === '|' || ch === ';' || ch === '\n' || ch === '(') {
71
+ atCommandStart = true;
72
+ i += 1;
73
+ continue;
74
+ }
75
+ if (/\s/.test(ch)) {
76
+ i += 1;
77
+ continue;
78
+ }
79
+ if (atCommandStart && command.startsWith('cd', i) && /\s/.test(command[i + 2] ?? '')) {
80
+ const { target, next } = readCdTarget(command, i + 2);
81
+ if (target.length > 0) targets.push(target);
82
+ i = next;
83
+ atCommandStart = false;
84
+ continue;
85
+ }
86
+ atCommandStart = false;
87
+ i += 1;
88
+ }
89
+ return targets;
90
+ }
91
+
92
+ /**
93
+ * Read one `cd` argument starting just after the `cd` keyword. Stops at an
94
+ * unquoted command terminator. Backslash escapes are carried through so the
95
+ * caller sees exactly what the shell would.
96
+ */
97
+ function readCdTarget(command: string, start: number): { target: string; next: number } {
98
+ let i = start;
99
+ while (i < command.length && /\s/.test(command[i])) i += 1;
100
+ const begin = i;
101
+ let end = i;
102
+ while (i < command.length) {
103
+ const ch = command[i];
104
+ if (ch === "'") {
105
+ const close = command.indexOf("'", i + 1);
106
+ i = close === -1 ? command.length : close + 1;
107
+ end = i;
108
+ continue;
109
+ }
110
+ if (ch === '"') {
111
+ const close = command.indexOf('"', i + 1);
112
+ i = close === -1 ? command.length : close + 1;
113
+ end = i;
114
+ continue;
115
+ }
116
+ if (ch === '\\' && i + 1 < command.length) {
117
+ i += 2;
118
+ end = i;
119
+ continue;
120
+ }
121
+ if (ch === '&' || ch === '|' || ch === ';' || ch === '\n') break;
122
+ i += 1;
123
+ if (!/\s/.test(ch) || command.slice(begin, i).trim().length > 0) end = i;
124
+ }
125
+ return { target: command.slice(begin, end).trim(), next: i };
126
+ }
127
+
128
+ /**
129
+ * Substitute the packager-supplied variables, strip quoting, and drop the
130
+ * target to `null` when anything unresolvable remains. `null` means "this
131
+ * gate cannot answer whether this path exists", never "it does not exist".
132
+ */
133
+ function resolveTarget(target: string, options: ResolveBuildCommandPathsOptions): string | null {
134
+ const substituted = target
135
+ .replaceAll('$CELILO_MODULE_SOURCE_DIR', options.moduleSourceDir)
136
+ .replaceAll('$(pwd)', options.buildDir)
137
+ .replaceAll('$STAGE', options.buildDir)
138
+ .replaceAll('"', '')
139
+ .replaceAll("'", '');
140
+ if (/[$`*?~[(]/.test(substituted)) return null;
141
+ if (substituted.startsWith('-')) return null; // `cd -` / flags, not a path
142
+ return substituted;
143
+ }
144
+
145
+ export function resolveBuildCommandPaths(
146
+ command: string,
147
+ options: ResolveBuildCommandPathsOptions,
148
+ ): BuildPathViolation[] {
149
+ const exists = options.exists ?? existsSync;
150
+ const violations: BuildPathViolation[] = [];
151
+ // The simulated working directory of the build. `null` means unknown: the
152
+ // previous cd could not be resolved, so a later relative cd cannot be either.
153
+ let cwd: string | null = options.buildDir;
154
+ for (const rawTarget of findCdTargets(command)) {
155
+ const target = resolveTarget(rawTarget, options);
156
+ if (target === null) {
157
+ cwd = null;
158
+ continue;
159
+ }
160
+ const resolved: string | null = target.startsWith('/')
161
+ ? target
162
+ : cwd === null
163
+ ? null
164
+ : resolve(cwd, target);
165
+ if (resolved === null) {
166
+ cwd = null;
167
+ continue;
168
+ }
169
+ if (!exists(resolved)) {
170
+ violations.push({ rawPath: rawTarget, resolvedPath: resolved });
171
+ }
172
+ cwd = resolved;
173
+ }
174
+ return violations;
175
+ }
@@ -7,10 +7,11 @@
7
7
  */
8
8
 
9
9
  import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
10
+ import { execFileSync } from 'node:child_process';
10
11
  import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
11
12
  import { tmpdir } from 'node:os';
12
13
  import { join } from 'node:path';
13
- import { computeChecksums } from './build';
14
+ import { buildModule, computeChecksums } from './build';
14
15
 
15
16
  describe('computeChecksums — scripts/node_modules bundling (ISS-0046)', () => {
16
17
  let dir: string;
@@ -73,3 +74,72 @@ describe('computeChecksums — scripts/node_modules bundling (ISS-0046)', () =>
73
74
  expect(paths.some((p) => p.startsWith('e2e/'))).toBe(false);
74
75
  });
75
76
  });
77
+
78
+ /**
79
+ * The pack-staging branch must not eat the hook runtime (celilo#1310).
80
+ *
81
+ * `buildModule` stages through `bun pm pack` whenever a module has a root
82
+ * package.json, and npm-pack semantics drop every `node_modules` — a `files`
83
+ * entry naming the path does not override it. That silently contradicted
84
+ * `includeNodeModulesPath`, which exists to ship `scripts/node_modules` in full
85
+ * so hooks resolve their deps on a target with no reachable registry.
86
+ *
87
+ * Nothing in `modules/` can reach this branch: every module there has only
88
+ * `scripts/package.json` and takes the `cpSync` path. So the regression is only
89
+ * visible against a fixture shaped like a module that is ALSO a bun project.
90
+ */
91
+ describe('buildModule — a root package.json must not strip the hook runtime', () => {
92
+ let source: string;
93
+ let out: string;
94
+
95
+ function write(rel: string, content: string): void {
96
+ const full = join(source, rel);
97
+ mkdirSync(join(full, '..'), { recursive: true });
98
+ writeFileSync(full, content, 'utf-8');
99
+ }
100
+
101
+ beforeEach(() => {
102
+ source = mkdtempSync(join(tmpdir(), 'celilo-pack-root-pkg-'));
103
+ out = join(mkdtempSync(join(tmpdir(), 'celilo-pack-out-')), 'm.netapp');
104
+
105
+ write('manifest.yml', 'celilo_contract: "1.0"\nid: root-pkg-module\nname: M\nversion: 0.1.0\n');
106
+ // The shape that triggers it: a module that is also a bun project. `files`
107
+ // deliberately names scripts/ — proving the loss is npm-pack semantics and
108
+ // not a missing entry.
109
+ write(
110
+ 'package.json',
111
+ JSON.stringify({
112
+ name: 'root-pkg-module',
113
+ version: '0.1.0',
114
+ private: true,
115
+ files: ['manifest.yml', 'scripts/'],
116
+ }),
117
+ );
118
+ write('scripts/on_install.ts', "import '@celilo/capabilities';\n");
119
+ write(
120
+ 'scripts/package.json',
121
+ JSON.stringify({ name: 'm-hooks', dependencies: { tldts: '^7.0.0' } }),
122
+ );
123
+ write('scripts/node_modules/tldts/index.js', 'module.exports = {};\n');
124
+ write(
125
+ 'scripts/node_modules/tldts/package.json',
126
+ JSON.stringify({ name: 'tldts', version: '7.4.12' }),
127
+ );
128
+ write('scripts/node_modules/.bin/tldts', '#!/bin/sh\n');
129
+ });
130
+
131
+ afterEach(() => {
132
+ rmSync(source, { recursive: true, force: true });
133
+ rmSync(join(out, '..'), { recursive: true, force: true });
134
+ });
135
+
136
+ it('ships scripts/node_modules even though bun pm pack dropped it', async () => {
137
+ const result = await buildModule({ sourceDir: source, outputPath: out });
138
+ expect(result.success).toBe(true);
139
+
140
+ const listed = execFileSync('tar', ['tzf', out], { encoding: 'utf-8' }).split('\n');
141
+ expect(listed).toContain('scripts/node_modules/tldts/index.js');
142
+ // The one thing the runtime closure still excludes.
143
+ expect(listed).not.toContain('scripts/node_modules/.bin/tldts');
144
+ });
145
+ });
@@ -9,6 +9,7 @@ import { parse as parseYaml } from 'yaml';
9
9
  import { log } from '../../cli/prompts';
10
10
  import { formatViolations, scanModuleDirectory } from '../../policy/module-script-scan';
11
11
  import { validateModuleDirectory } from '../import';
12
+ import { resolveBuildCommandPaths } from './build-paths';
12
13
  import { computeFileChecksum } from './checksum';
13
14
  import { classifyModulePath, includeNodeModulesPath } from './package-rules';
14
15
  import { signChecksums } from './signature';
@@ -23,6 +24,37 @@ export interface ChecksumsData {
23
24
  files: Record<string, string>; // filepath -> xxhash
24
25
  }
25
26
 
27
+ /**
28
+ * Put `scripts/node_modules` back after a `bun pm pack` staging.
29
+ *
30
+ * `bun pm pack` drops every `node_modules` unconditionally — that is npm-pack
31
+ * semantics, and a `files` entry naming the path does not override it. But the
32
+ * packager is explicit downstream (`includeNodeModulesPath`) that
33
+ * `scripts/node_modules` is the module's HOOK RUNTIME and ships in full, so
34
+ * hooks resolve their third-party deps on a target with no reachable registry.
35
+ *
36
+ * Those two rules disagreed, and which one won was decided by whether a module
37
+ * happened to have a root `package.json`. Every module in `modules/` has only
38
+ * `scripts/package.json`, so all of them take the `cpSync` branch below and
39
+ * keep their runtime. A module that is ALSO a bun project — a build script, a
40
+ * data pipeline, its own tests — takes this branch instead and shipped without
41
+ * one. Nothing caught it: package, publish, import and deploy all succeed, and
42
+ * the first symptom is the hook dying on the target with
43
+ * `Cannot find package '@celilo/capabilities'` (celilo#1310).
44
+ *
45
+ * The hook runtime is not build input, so `files` has no business filtering it.
46
+ * Symlinks are materialised for the same reason the `cpSync` branch does it: a
47
+ * `file:` workspace link would point at a path the target does not have.
48
+ */
49
+ function stageHookRuntime(sourceDir: string, buildDir: string): void {
50
+ const from = join(sourceDir, 'scripts', 'node_modules');
51
+ if (!existsSync(from)) return;
52
+ cpSync(from, join(buildDir, 'scripts', 'node_modules'), {
53
+ recursive: true,
54
+ dereference: true,
55
+ });
56
+ }
57
+
26
58
  /**
27
59
  * Module build options
28
60
  */
@@ -215,6 +247,7 @@ export async function buildModule(options: ModuleBuildOptions): Promise<ModuleBu
215
247
  timeout: 60_000,
216
248
  });
217
249
  rmSync(tarballs[0], { force: true });
250
+ stageHookRuntime(sourceDir, buildDir);
218
251
  } catch (err) {
219
252
  const errMsg = err instanceof Error ? err.message : String(err);
220
253
  return {
@@ -258,6 +291,33 @@ export async function buildModule(options: ModuleBuildOptions): Promise<ModuleBu
258
291
  if (manifest.build?.command || manifest.build?.script) {
259
292
  log.info(`Building module (${manifest.build.command ? 'command' : 'script'})...`);
260
293
 
294
+ // Publish-time gate on the build command's paths (D3 of
295
+ // control-plane-stops-building-modules). A `cd` whose target does not
296
+ // resolve where the command runs means the module depends on inputs it
297
+ // does not carry. celilo-registry did exactly this for four months
298
+ // (`cd ../../packages/registry-server`) and the failure surfaced on a
299
+ // production control plane, months after publish, as a /bin/sh error.
300
+ // Refuse here, at the only point where a human is watching, and name the
301
+ // path. Targets the gate cannot resolve (untracked shell variables,
302
+ // command substitution) are skipped, never guessed at.
303
+ if (manifest.build.command) {
304
+ const pathViolations = resolveBuildCommandPaths(manifest.build.command, {
305
+ moduleSourceDir: sourceDir,
306
+ buildDir,
307
+ });
308
+ if (pathViolations.length > 0) {
309
+ return {
310
+ success: false,
311
+ error: [
312
+ `Refusing to build ${moduleId}: the build command references a path that does not exist where the command runs.`,
313
+ ...pathViolations.map((v) => ` ${v.rawPath}\n resolves to: ${v.resolvedPath}`),
314
+ '',
315
+ 'A build input that does not resolve at publish time makes the module unbuildable wherever it is installed. Fix the path in manifest.yml (keep it module-local, or reach the monorepo through $CELILO_MODULE_SOURCE_DIR) and re-publish.',
316
+ ].join('\n'),
317
+ };
318
+ }
319
+ }
320
+
261
321
  // Resolve `workspace:` build deps (ISS-0147 / celilo#216). The staged
262
322
  // buildDir has no workspace root, so a sibling monorepo package pulled in
263
323
  // via `workspace:^` won't resolve during the build's `bun install`.
@@ -19,12 +19,8 @@ export interface IntegrityViolation {
19
19
  * being old rather than by the files having changed. Only `auditModule`
20
20
  * produces it; package verification compares a package to its own manifest
21
21
  * and cannot be stale in this sense.
22
- *
23
- * `stale-generated` is the second plane (D3): a verbatim role asset in
24
- * `generated/` whose bytes are not the installed module's. That is what
25
- * celilo#925 was, and what would ship on the next deploy.
26
22
  */
27
- type: 'missing' | 'modified' | 'extra' | 'stale-baseline' | 'stale-generated';
23
+ type: 'missing' | 'modified' | 'extra' | 'stale-baseline';
28
24
  /**
29
25
  * What the baseline says the file should hash to, and what it actually
30
26
  * hashes to. Both optional because not every violation is about a digest —
@@ -82,28 +82,36 @@ export const SWALLOWED_REFUSAL_RULE = 'swallowed framework refusal';
82
82
  */
83
83
  const CLI_SPAWN_RE =
84
84
  /\b(?:execSync|execFileSync|execFile|exec|spawnSync|spawn)\s*\(\s*(?:['"`]\s*celilo(?=\s|['"`])|`[^`]*\bcelilo(?=\s|`))/;
85
+
86
+ /**
87
+ * The spawn rule runs against the WHOLE FILE, not one line at a time. The
88
+ * per-line pass above could not see a call whose argv wrapped onto the next
89
+ * line — `execFileSync(\n 'celilo', …` — and that blind spot was live:
90
+ * celilo-registry setup.ts carried a four-line spawn and iptables
91
+ * firewall-functions.ts three more, all scanning clean while the gate was
92
+ * green (found by ce-y0we's reach check on 2026-09-09). The regex itself
93
+ * needs no change — `\s*` already crosses newlines, and `[^\`]*` never
94
+ * matches a backtick so a template cannot swallow the rest of the file — it
95
+ * just has to be applied to text wider than a line. Line numbers come from
96
+ * the match index, so reports still name the line an author fixes.
97
+ */
98
+ const CLI_SPAWN_BLOCK_RE = new RegExp(CLI_SPAWN_RE.source, 'g');
99
+
100
+ /** Same wording the rule carried when it ran per line; moved with the pass. */
101
+ const CLI_SPAWN_HINT =
102
+ 'A jailed hook has no celilo binary and no shell, so this dies with ' +
103
+ '"Executable not found in $PATH" (celilo#1225). Replace the spawn with an ' +
104
+ 'injected capability — context.secrets/context.config for self-config and ' +
105
+ 'self-secrets (openspec/changes/hook-owned-state) — or move the operation ' +
106
+ 'into the framework the way celilo-mgmt on_install did (5e0e624d).';
107
+
85
108
  const FRAMEWORK_CALL_RE = new RegExp(`${CLI_SPAWN_RE.source}|\\brunCelilo\\s*\\(`);
86
109
 
87
110
  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
- },
111
+ // NOTE: the jailed-CLI-spawn rule is NOT in this array — it runs in its own
112
+ // whole-file pass in scanModuleScriptSource (CLI_SPAWN_BLOCK_RE), because a
113
+ // per-line match missed every spawn whose argv wrapped. Everything else
114
+ // stays per-line.
107
115
  {
108
116
  rule: 'raw ssh invocation (StrictHostKeyChecking)',
109
117
  re: /StrictHostKeyChecking/,
@@ -200,6 +208,20 @@ export function scanModuleScriptSource(file: string, source: string): ScanViolat
200
208
  const violations: ScanViolation[] = [];
201
209
  const lines = source.split('\n');
202
210
 
211
+ // The jailed-CLI-spawn pass: whole-file, so a spawn whose argv wraps onto
212
+ // the next line is still seen (see CLI_SPAWN_BLOCK_RE for the blind spot
213
+ // that motivated this). Reported on the line the CALL starts, because that
214
+ // is the site an author fixes.
215
+ CLI_SPAWN_BLOCK_RE.lastIndex = 0;
216
+ for (let m = CLI_SPAWN_BLOCK_RE.exec(source); m !== null; m = CLI_SPAWN_BLOCK_RE.exec(source)) {
217
+ violations.push({
218
+ file,
219
+ line: source.slice(0, m.index).split('\n').length,
220
+ rule: JAILED_CLI_SPAWN_RULE,
221
+ hint: CLI_SPAWN_HINT,
222
+ });
223
+ }
224
+
203
225
  lines.forEach((text, i) => {
204
226
  for (const { rule, re, hint } of PATTERN_RULES) {
205
227
  if (re.test(text)) violations.push({ file, line: i + 1, rule, hint });
@@ -337,85 +359,19 @@ export interface PolicyDebtEntry {
337
359
  * the test goes red, and the entry must be deleted in the same change.
338
360
  */
339
361
  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
362
  {
354
363
  module: 'dnsmasq-dhcp',
355
364
  file: 'scripts/dhcp-server-functions.ts',
356
365
  matches: 1,
357
366
  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',
367
+ 'capability provider, no hook entry imports it — ce-mqay (jail does not constrain it; conversion is dedup, not breakage)',
378
368
  },
379
369
  {
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',
370
+ module: 'iptables',
371
+ file: 'scripts/firewall-functions.ts',
416
372
  matches: 3,
417
373
  reason:
418
- 'hook-owned-state 5.5 (vpn_endpoint/vpn_server_public_key/client_pool, category 3; secret set)',
374
+ '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',
419
375
  },
420
376
  ];
421
377
 
@@ -427,25 +383,13 @@ export const JAILED_CLI_SPAWN_DEBT: readonly PolicyDebtEntry[] = [
427
383
  * same guarded rot: when a site converts, the count drops, the debt test goes
428
384
  * red, and the entry must be deleted in the same change.
429
385
  *
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.
386
+ * EMPTY as of ce-y0we (2026-09-09): knot-unbound-internal's on-install dropped
387
+ * its last swallow on 2026-09-07 (ce-pvii), wireguard's health-check converted
388
+ * the same evening (01575380), and celilo-mgmt's on_backup machine-pool read
389
+ * retired the last entry. A NEW swallowed refusal must never land here — it
390
+ * gets fixed, not tracked.
433
391
  */
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
- ];
392
+ export const SWALLOWED_REFUSAL_DEBT: readonly PolicyDebtEntry[] = [];
449
393
 
450
394
  /**
451
395
  * The files inside `@celilo/capabilities` that implement the remote-exec
@@ -85,7 +85,11 @@ describe('recurrence gate: the jailed-CLI-spawn debt list is honest', () => {
85
85
  });
86
86
 
87
87
  test('the debt list is non-trivial (the guard actually reached the entries)', () => {
88
- expect(JAILED_CLI_SPAWN_DEBT.length).toBeGreaterThan(5);
88
+ // Was >5 while the list still carried the hook-owned-state 5.5 batch. The
89
+ // conversions retire entries faster than new debt arrives (ce-y0we), so
90
+ // the floor is now "at least one tracked site" — enough to prove the test
91
+ // reached the list without pinning the retirement schedule.
92
+ expect(JAILED_CLI_SPAWN_DEBT.length).toBeGreaterThan(0);
89
93
  });
90
94
  });
91
95
 
@@ -212,9 +212,9 @@ describe('recurrence gate: a hook does not swallow a refused framework call', ()
212
212
  }
213
213
  });
214
214
 
215
- test('today’s tree: the remaining known sites are flagged, and nothing else is', () => {
215
+ test('today’s tree: no swallowed refusals anywhere, and the scan still reaches every module', () => {
216
216
  // Raw scan, deliberately NOT scanModuleDirectory: the debt list below
217
- // exempts these same files so the merged gate stays green while
217
+ // exempts the same files so the merged gate stays green while
218
218
  // hook-owned-state converts them, and this assertion has to outlive that
219
219
  // exemption to prove the rule still reaches them.
220
220
  const violations = moduleDirs().flatMap((d) =>
@@ -225,15 +225,11 @@ describe('recurrence gate: a hook does not swallow a refused framework call', ()
225
225
  ),
226
226
  );
227
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
- ]);
228
+ // Empty since ce-y0we (2026-09-09): on_backup.ts:52 was the last site —
229
+ // its machine-pool read is staged input now — and the two before it
230
+ // converted on 2026-09-07 (ce-pvii and 01575380). A NEW site appearing
231
+ // here is the next instance this scan exists to stop.
232
+ expect(sites, `Module script policy violations:\n${formatViolations(violations)}`).toEqual([]);
237
233
  });
238
234
  });
239
235
 
@@ -257,9 +253,12 @@ describe('recurrence gate: the swallow debt list is honest', () => {
257
253
  }
258
254
  });
259
255
 
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);
256
+ test('the debt list is retired (empty), and this test pins that fact', () => {
257
+ // The list held three entries when the gate landed; ce-pvii and 01575380
258
+ // converted two on 2026-09-07 and ce-y0we retired the last (on_backup's
259
+ // machine-pool read) on 2026-09-09. Kept as an explicit pin so a future
260
+ // entry has to be justified against this sentence: a swallowed refusal is
261
+ // a defect to fix, never debt to track.
262
+ expect(SWALLOWED_REFUSAL_DEBT).toEqual([]);
264
263
  });
265
264
  });
@@ -48,14 +48,14 @@ describe('RegistryClient constructor', () => {
48
48
 
49
49
  afterEach(() => {
50
50
  if (origEnv === undefined) {
51
- process.env.CELILO_REGISTRY_URL = undefined;
51
+ delete process.env.CELILO_REGISTRY_URL;
52
52
  } else {
53
53
  process.env.CELILO_REGISTRY_URL = origEnv;
54
54
  }
55
55
  });
56
56
 
57
57
  test('uses DEFAULT_REGISTRY when no arg and no env var', () => {
58
- process.env.CELILO_REGISTRY_URL = undefined;
58
+ delete process.env.CELILO_REGISTRY_URL;
59
59
  const client = new RegistryClient();
60
60
  expect(client.baseUrl).toBe(DEFAULT_REGISTRY);
61
61
  });
@@ -36,7 +36,7 @@ describe('secret storage', () => {
36
36
  afterEach(() => {
37
37
  // Cleanup
38
38
  rmSync(testDir, { recursive: true, force: true });
39
- process.env.CELILO_MASTER_KEY_PATH = undefined;
39
+ delete process.env.CELILO_MASTER_KEY_PATH;
40
40
  });
41
41
 
42
42
  test('stores and retrieves system secret', async () => {