@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.
- package/drizzle/0031_module_config_source.sql +20 -0
- package/drizzle/meta/_journal.json +8 -1
- package/package.json +2 -2
- package/schemas/system_config.json +2 -1
- package/src/capabilities/public-web-publish.test.ts +61 -0
- package/src/cli/commands/firewall-interface-list.test.ts +156 -7
- package/src/cli/commands/firewall-interface-list.ts +73 -7
- package/src/cli/commands/machine-add.ts +12 -55
- package/src/cli/commands/module-config.test.ts +20 -1
- package/src/cli/commands/module-import.ts +1 -1
- package/src/cli/commands/module-update.test.ts +82 -0
- package/src/cli/commands/module-update.ts +14 -4
- package/src/cli/commands/monitor.ts +2 -10
- package/src/cli/commands/restore.ts +16 -6
- package/src/cli/generate-zsh-completion.ts +1 -1
- package/src/cli/index.ts +4 -3
- package/src/cli/restore-migration-failure.test.ts +159 -0
- package/src/db/client.ts +5 -0
- package/src/db/migrate.test.ts +61 -135
- package/src/db/migrate.ts +7 -2
- package/src/db/schema.ts +10 -0
- package/src/hooks/broker.test.ts +106 -2
- package/src/hooks/broker.ts +91 -1
- package/src/hooks/capability-loader-firewall.test.ts +37 -0
- package/src/hooks/capability-loader.ts +15 -1
- package/src/hooks/define-hook.test.ts +4 -3
- package/src/hooks/executor.test.ts +19 -18
- package/src/hooks/executor.ts +82 -11
- package/src/hooks/hook-jail-toolchain-reach.test.ts +3 -2
- package/src/hooks/hook-jail-unreachability.test.ts +4 -3
- package/src/hooks/hook-protocol.ts +46 -1
- package/src/hooks/hook-runner.ts +36 -0
- package/src/hooks/hook-store-proxy.test.ts +109 -0
- package/src/hooks/hook-store-proxy.ts +85 -0
- package/src/hooks/hook-store.test.ts +162 -0
- package/src/hooks/hook-store.ts +290 -0
- package/src/hooks/hook-timeout.test.ts +3 -2
- package/src/hooks/hook-trespass.test.ts +29 -5
- package/src/hooks/jail.test.ts +1 -1
- package/src/hooks/jail.ts +14 -7
- package/src/hooks/mount-set.test.ts +208 -0
- package/src/hooks/mount-set.ts +62 -14
- package/src/hooks/run-named-hook.ts +2 -0
- package/src/hooks/test-fixtures/jail-probe-hook.ts +1 -1
- package/src/hooks/test-fixtures/on-restore-staging-hook.ts +26 -0
- package/src/hooks/test-fixtures/store-backed.ts +47 -0
- package/src/hooks/test-fixtures/store-writing-hook.ts +63 -0
- package/src/hooks/unjailed-lint.test.ts +22 -6
- package/src/manifest/schema.ts +1 -0
- package/src/module/packaging/build.ts +70 -2
- package/src/module/web-root.ts +17 -1
- package/src/policy/fixture-capability-coverage.test.ts +322 -0
- package/src/policy/module-script-scan.test.ts +42 -1
- package/src/policy/module-script-scan.ts +275 -5
- package/src/policy/no-hand-built-ssh.test.ts +34 -1
- package/src/policy/no-swallowed-refusal.test.ts +265 -0
- package/src/policy/no-tar-shell-out-in-services.test.ts +43 -0
- package/src/registry/client.test.ts +149 -0
- package/src/registry/client.ts +203 -11
- package/src/services/alerting/format.test.ts +57 -0
- package/src/services/alerting/format.ts +24 -0
- package/src/services/alerting/run-monitor.ts +2 -2
- package/src/services/backup-create.ts +7 -7
- package/src/services/backup-envelope-roundtrip.test.ts +45 -2
- package/src/services/backup-restore.ts +8 -4
- package/src/services/bus-interview.ts +37 -14
- package/src/services/config-provenance.ts +4 -0
- package/src/services/control-plane-bootstrap.test.ts +121 -1
- package/src/services/control-plane-bootstrap.ts +51 -4
- package/src/services/deploy-preflight.ts +8 -2
- package/src/services/deploy-validation.test.ts +22 -0
- package/src/services/deploy-validation.ts +8 -0
- package/src/services/dns-discovery.test.ts +54 -0
- package/src/services/dns-discovery.ts +47 -5
- package/src/services/fleet-key.test.ts +66 -2
- package/src/services/fleet-key.ts +54 -0
- package/src/services/health-runner.ts +2 -0
- package/src/services/module-config.ts +20 -2
- package/src/services/module-deploy.dns-repoint.test.ts +187 -0
- package/src/services/module-deploy.ts +163 -1
- package/src/services/module-validator/git-hygiene.test.ts +122 -3
- package/src/services/module-validator/git-hygiene.ts +83 -14
- package/src/services/restore-from-file.test.ts +20 -0
- package/src/services/restore-from-file.ts +21 -6
- package/src/services/static-content-converge.test.ts +140 -2
- package/src/services/static-content-converge.ts +55 -8
- package/src/services/system-config-schema-types.ts +1 -1
- package/src/services/system-config-validator.test.ts +36 -0
- package/src/services/system-config-validator.ts +11 -0
- package/src/services/trusted-sources.test.ts +30 -0
- package/src/services/trusted-sources.ts +47 -10
- package/src/templates/generator.ts +9 -2
- 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
|
-
|
|
171
|
-
|
|
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
|
+
});
|