@celilo/cli 2.2.1 → 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.
- package/CELILO_CORE_MODULES.md +1 -0
- package/CELILO_SUBSYSTEMS.md +1 -1
- package/README.md +1 -1
- package/drizzle/0032_module_jail_policies.sql +28 -0
- package/drizzle/0033_build_bus_hook_runs.sql +37 -0
- package/drizzle/meta/_journal.json +15 -1
- package/package.json +3 -3
- package/src/__integration__/container-services-cli.integration.test.ts +1 -1
- package/src/api/sessions.test.ts +3 -3
- package/src/api-clients/proxmox.ts +10 -7
- package/src/cli/backup-rename.test.ts +3 -3
- package/src/cli/cli.test.ts +1 -1
- package/src/cli/commands/events.test.ts +2 -2
- package/src/cli/commands/firewall-interface-list.test.ts +2 -2
- package/src/cli/commands/machine-list.test.ts +57 -0
- package/src/cli/commands/machine-list.ts +35 -4
- package/src/cli/commands/module-health.ts +1 -0
- package/src/cli/commands/module-import-registry.test.ts +1 -1
- package/src/cli/commands/module-jail.test.ts +242 -0
- package/src/cli/commands/module-jail.ts +227 -0
- package/src/cli/commands/module-list-jail.test.ts +136 -0
- package/src/cli/commands/module-list.ts +30 -3
- package/src/cli/commands/module-publish.test.ts +6 -6
- package/src/cli/commands/module-update.test.ts +22 -7
- package/src/cli/commands/module-update.ts +4 -1
- package/src/cli/commands/module-upgrade.test.ts +115 -1
- package/src/cli/commands/module-upgrade.ts +23 -1
- package/src/cli/commands/module-verify.test.ts +1 -1
- package/src/cli/commands/publish/execute.ts +4 -1
- package/src/cli/commands/publish/index.ts +11 -1
- package/src/cli/commands/publish/module-registry.test.ts +24 -1
- package/src/cli/commands/publish/module-registry.ts +23 -4
- package/src/cli/commands/publish/plan.ts +1 -1
- package/src/cli/commands/publish/types.ts +7 -0
- package/src/cli/commands/registry-owner.test.ts +1 -1
- package/src/cli/commands/registry-token.test.ts +1 -1
- package/src/cli/commands/system-audit.ts +20 -6
- package/src/cli/commands/system-doctor-remediation-gate.test.ts +295 -0
- package/src/cli/commands/system-doctor.test.ts +76 -10
- package/src/cli/commands/system-doctor.ts +34 -3
- package/src/cli/commands/system-init-deprecation.test.ts +1 -1
- package/src/cli/commands/system-update.ts +5 -0
- package/src/cli/completion.ts +8 -2
- package/src/cli/flag-surface-gate.test.ts +279 -0
- package/src/cli/index.ts +68 -2
- package/src/cli/parser.test.ts +37 -1
- package/src/cli/restore-command.test.ts +3 -3
- package/src/cli/restore-migration-failure.test.ts +2 -2
- package/src/cli/tui/audit-state.ts +2 -0
- package/src/config/paths.test.ts +19 -19
- package/src/db/client.test.ts +46 -1
- package/src/db/client.ts +26 -0
- package/src/db/schema.ts +63 -0
- package/src/hooks/capability-loader-firewall.test.ts +9 -1
- package/src/hooks/capability-loader.ts +8 -0
- package/src/hooks/executor.test.ts +86 -12
- package/src/hooks/executor.ts +31 -1
- package/src/hooks/hook-jail-unreachability.test.ts +17 -18
- package/src/hooks/hook-trespass.test.ts +9 -2
- package/src/hooks/jail.test.ts +105 -17
- package/src/hooks/jail.ts +47 -10
- package/src/manifest/contracts/v1.ts +13 -0
- package/src/policy/module-script-scan.ts +52 -68
- package/src/policy/no-hand-built-ssh.test.ts +5 -1
- package/src/policy/no-swallowed-refusal.test.ts +14 -14
- package/src/registry/client.test.ts +2 -2
- package/src/secrets/storage.test.ts +1 -1
- package/src/services/alerting/keys.test.ts +4 -0
- package/src/services/alerting/keys.ts +12 -2
- package/src/services/audit/health.test.ts +97 -2
- package/src/services/audit/health.ts +64 -2
- package/src/services/audit/index.test.ts +22 -0
- package/src/services/audit/index.ts +8 -2
- package/src/services/audit/jail-exemptions.test.ts +42 -0
- package/src/services/audit/jail-exemptions.ts +44 -0
- package/src/services/audit/module-integrity.test.ts +23 -1
- package/src/services/audit/module-integrity.ts +7 -2
- package/src/services/audit/types.ts +2 -1
- package/src/services/backup-create.ts +9 -0
- package/src/services/backup-envelope-roundtrip.test.ts +1 -1
- package/src/services/backup-in-flight-refusal.test.ts +1 -1
- package/src/services/build-bus/hook-dispatch-mgmt.test.ts +48 -0
- package/src/services/build-bus/hook-dispatcher.ts +49 -1
- package/src/services/bus-ensure-flow.test.ts +1 -1
- package/src/services/bus-interview-park.test.ts +2 -2
- package/src/services/bus-interview.test.ts +2 -2
- package/src/services/bus-secret-flow.test.ts +1 -1
- package/src/services/celilo-events.test.ts +1 -1
- package/src/services/celilo-mgmt-hooks.test.ts +23 -5
- package/src/services/container-service.test.ts +1 -1
- package/src/services/cross-module-read.test.ts +2 -2
- package/src/services/deploy-preflight.ts +7 -0
- package/src/services/deployed-systems.ts +1 -1
- package/src/services/fleet-checks.test.ts +123 -4
- package/src/services/fleet-checks.ts +176 -3
- package/src/services/health-runner.test.ts +87 -2
- package/src/services/health-runner.ts +40 -11
- package/src/services/infrastructure-selector.test.ts +1 -1
- package/src/services/jail-exemptions.test.ts +125 -0
- package/src/services/jail-exemptions.ts +81 -0
- package/src/services/machine-pool.test.ts +1 -1
- package/src/services/module-deploy.ts +12 -4
- package/src/services/module-subscriptions.test.ts +2 -2
- package/src/services/network-discovery.test.ts +64 -1
- package/src/services/network-discovery.ts +30 -5
- package/src/services/responder-probe.test.ts +1 -1
- package/src/services/restore-from-file.test.ts +4 -4
- package/src/services/restore-preflight.test.ts +1 -1
- package/src/services/ssh-key-manager.test.ts +2 -2
- package/src/services/system-state-stage.test.ts +2 -2
- package/src/services/update/orchestrator.test.ts +2 -0
- package/src/test-utils/bus-responder.ts +1 -1
package/src/hooks/jail.test.ts
CHANGED
|
@@ -209,9 +209,13 @@ describe('the three policies', () => {
|
|
|
209
209
|
});
|
|
210
210
|
|
|
211
211
|
test("'required' turns an unavailable jail into a hard failure (task 4.5)", () => {
|
|
212
|
-
expect(
|
|
213
|
-
planJailedSpawn(CMD, deriveMountSet(REQUEST), UNAVAILABLE, 'required', allPresent),
|
|
214
|
-
|
|
212
|
+
expect(
|
|
213
|
+
() => planJailedSpawn(CMD, deriveMountSet(REQUEST), UNAVAILABLE, 'required', allPresent),
|
|
214
|
+
// Not /CELILO_HOOK_JAIL=required/. Since hook-jail-config-surface the
|
|
215
|
+
// policy can come from the stored `hooks.jail_policy` row or the
|
|
216
|
+
// default, so a message naming the variable names a source that may be
|
|
217
|
+
// unset.
|
|
218
|
+
).toThrow(/hook jail policy is 'required'/);
|
|
215
219
|
});
|
|
216
220
|
|
|
217
221
|
test("'required' names the reason, so the operator can act on it", () => {
|
|
@@ -291,30 +295,42 @@ describe('jailPolicy reads the operator’s switch', () => {
|
|
|
291
295
|
|
|
292
296
|
describe('resolveJailPolicy applies D2 precedence (hook-jail-config-surface)', () => {
|
|
293
297
|
test('an env value set and non-empty wins over stored config', () => {
|
|
294
|
-
expect(resolveJailPolicy('required', 'off')).toEqual({
|
|
298
|
+
expect(resolveJailPolicy('required', undefined, 'off')).toEqual({
|
|
299
|
+
policy: 'required',
|
|
300
|
+
source: 'env',
|
|
301
|
+
});
|
|
295
302
|
});
|
|
296
303
|
|
|
297
304
|
test('an EMPTY STRING env value counts as unset and falls through to config', () => {
|
|
298
305
|
// jailPolicy() already treats '' as unset, so the resolver agrees rather
|
|
299
306
|
// than inventing a second rule for what counts as set.
|
|
300
|
-
expect(resolveJailPolicy('', 'off')).toEqual({ policy: 'off', source: 'config' });
|
|
307
|
+
expect(resolveJailPolicy('', undefined, 'off')).toEqual({ policy: 'off', source: 'config' });
|
|
301
308
|
});
|
|
302
309
|
|
|
303
310
|
test('stored config wins when the env var is absent', () => {
|
|
304
|
-
expect(resolveJailPolicy(undefined, 'off')).toEqual({
|
|
305
|
-
|
|
311
|
+
expect(resolveJailPolicy(undefined, undefined, 'off')).toEqual({
|
|
312
|
+
policy: 'off',
|
|
313
|
+
source: 'config',
|
|
314
|
+
});
|
|
315
|
+
expect(resolveJailPolicy(undefined, undefined, 'required')).toEqual({
|
|
306
316
|
policy: 'required',
|
|
307
317
|
source: 'config',
|
|
308
318
|
});
|
|
309
319
|
});
|
|
310
320
|
|
|
311
|
-
test('
|
|
312
|
-
expect(resolveJailPolicy(undefined, undefined)).toEqual({
|
|
313
|
-
|
|
321
|
+
test('all absent resolves to off from the default (peba, ce-rez7)', () => {
|
|
322
|
+
expect(resolveJailPolicy(undefined, undefined, undefined)).toEqual({
|
|
323
|
+
policy: 'off',
|
|
324
|
+
source: 'default',
|
|
325
|
+
});
|
|
326
|
+
expect(resolveJailPolicy('', undefined, undefined)).toEqual({
|
|
327
|
+
policy: 'off',
|
|
328
|
+
source: 'default',
|
|
329
|
+
});
|
|
314
330
|
});
|
|
315
331
|
|
|
316
332
|
test('an explicit env auto still wins (peba, ce-rez7)', () => {
|
|
317
|
-
expect(resolveJailPolicy('auto', 'off')).toEqual({ policy: 'auto', source: 'env' });
|
|
333
|
+
expect(resolveJailPolicy('auto', undefined, 'off')).toEqual({ policy: 'auto', source: 'env' });
|
|
318
334
|
});
|
|
319
335
|
|
|
320
336
|
test('a stored empty jail_policy THROWS, it does not resolve to auto (peba, ce-8832)', () => {
|
|
@@ -322,8 +338,8 @@ describe('resolveJailPolicy applies D2 precedence (hook-jail-config-surface)', (
|
|
|
322
338
|
// can only arrive via a restore from file or a hand edit to the DB — the
|
|
323
339
|
// untrusted paths D3 exists to catch. It is outside the enum and fails
|
|
324
340
|
// closed like any other bad stored value.
|
|
325
|
-
expect(() => resolveJailPolicy(undefined, '')).toThrow(/is not a hook jail policy/);
|
|
326
|
-
expect(() => resolveJailPolicy(undefined, '')).toThrow(/hooks\.jail_policy=''/);
|
|
341
|
+
expect(() => resolveJailPolicy(undefined, undefined, '')).toThrow(/is not a hook jail policy/);
|
|
342
|
+
expect(() => resolveJailPolicy(undefined, undefined, '')).toThrow(/hooks\.jail_policy=''/);
|
|
327
343
|
});
|
|
328
344
|
|
|
329
345
|
test('a typo in the STORED row is refused, not silently treated as auto (D3)', () => {
|
|
@@ -331,15 +347,87 @@ describe('resolveJailPolicy applies D2 precedence (hook-jail-config-surface)', (
|
|
|
331
347
|
// foreign state, and a typo that silently meant `auto` reads as an armed
|
|
332
348
|
// jail when nothing is armed. The config path is no laxer than the env
|
|
333
349
|
// path.
|
|
334
|
-
expect(() => resolveJailPolicy(undefined, 'alwayssafe')).toThrow(
|
|
335
|
-
|
|
350
|
+
expect(() => resolveJailPolicy(undefined, undefined, 'alwayssafe')).toThrow(
|
|
351
|
+
/is not a hook jail policy/,
|
|
352
|
+
);
|
|
353
|
+
expect(() => resolveJailPolicy(undefined, undefined, 'alwayssafe')).toThrow(
|
|
336
354
|
/hooks\.jail_policy='alwayssafe'/,
|
|
337
355
|
);
|
|
338
356
|
});
|
|
339
357
|
|
|
340
358
|
test('a typo in the env value is still refused (unchanged jailPolicy behaviour)', () => {
|
|
341
|
-
expect(() => resolveJailPolicy('requried', undefined)).toThrow(
|
|
342
|
-
|
|
359
|
+
expect(() => resolveJailPolicy('requried', undefined, undefined)).toThrow(
|
|
360
|
+
/is not a hook jail policy/,
|
|
361
|
+
);
|
|
362
|
+
expect(() => resolveJailPolicy('requried', undefined, 'off')).toThrow(
|
|
363
|
+
/CELILO_HOOK_JAIL='requried'/,
|
|
364
|
+
);
|
|
365
|
+
});
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
describe('resolveJailPolicy applies the four-step precedence (per-module-jail-policy task 1.3)', () => {
|
|
369
|
+
// Each boundary gets its own assertion, not just the ends: the chain is
|
|
370
|
+
// fixed and rendered, and a test that only pins (env, default) cannot tell
|
|
371
|
+
// a reordering of module and config apart from a correct one.
|
|
372
|
+
|
|
373
|
+
test('env beats the module row', () => {
|
|
374
|
+
expect(resolveJailPolicy('required', 'off', 'auto')).toEqual({
|
|
375
|
+
policy: 'required',
|
|
376
|
+
source: 'env',
|
|
377
|
+
});
|
|
378
|
+
expect(resolveJailPolicy('off', 'auto', 'auto')).toEqual({ policy: 'off', source: 'env' });
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
test('the module row beats system config', () => {
|
|
382
|
+
expect(resolveJailPolicy(undefined, 'off', 'auto')).toEqual({
|
|
383
|
+
policy: 'off',
|
|
384
|
+
source: 'module',
|
|
385
|
+
});
|
|
386
|
+
expect(resolveJailPolicy(undefined, 'required', 'off')).toEqual({
|
|
387
|
+
policy: 'required',
|
|
388
|
+
source: 'module',
|
|
389
|
+
});
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
test('system config beats the default', () => {
|
|
393
|
+
expect(resolveJailPolicy(undefined, undefined, 'auto')).toEqual({
|
|
394
|
+
policy: 'auto',
|
|
395
|
+
source: 'config',
|
|
396
|
+
});
|
|
397
|
+
expect(resolveJailPolicy(undefined, undefined, 'required')).toEqual({
|
|
398
|
+
policy: 'required',
|
|
399
|
+
source: 'config',
|
|
400
|
+
});
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
test('the default is off (peba, ce-rez7)', () => {
|
|
404
|
+
expect(resolveJailPolicy(undefined, undefined, undefined)).toEqual({
|
|
405
|
+
policy: 'off',
|
|
406
|
+
source: 'default',
|
|
407
|
+
});
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
test('an absent module row falls through to system config — absent means follow the system', () => {
|
|
411
|
+
// The everyday case: a fleet with no per-module rows behaves exactly as
|
|
412
|
+
// it does today, which is the spec's own operator guarantee.
|
|
413
|
+
expect(resolveJailPolicy(undefined, undefined, 'auto')).toEqual({
|
|
414
|
+
policy: 'auto',
|
|
415
|
+
source: 'config',
|
|
416
|
+
});
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
test('a bad stored per-module value THROWS rather than coercing (task 1.3)', () => {
|
|
420
|
+
// The same rule as the system key (peba, ce-8832): a restore or a hand
|
|
421
|
+
// edit is a trust boundary, and a typo that silently meant `off` would
|
|
422
|
+
// jail a module the operator exempted — or the reverse. The error names
|
|
423
|
+
// its source so a reader can tell which stored row is junk.
|
|
424
|
+
expect(() => resolveJailPolicy(undefined, 'alwayssafe', 'off')).toThrow(
|
|
425
|
+
/is not a hook jail policy/,
|
|
426
|
+
);
|
|
427
|
+
expect(() => resolveJailPolicy(undefined, 'alwayssafe', 'off')).toThrow(
|
|
428
|
+
/module's jail policy='alwayssafe'/,
|
|
429
|
+
);
|
|
430
|
+
expect(() => resolveJailPolicy(undefined, '', 'off')).toThrow(/is not a hook jail policy/);
|
|
343
431
|
});
|
|
344
432
|
});
|
|
345
433
|
|
package/src/hooks/jail.ts
CHANGED
|
@@ -100,7 +100,9 @@ export function autoJailDefers(availability: JailAvailability): boolean {
|
|
|
100
100
|
}
|
|
101
101
|
|
|
102
102
|
/**
|
|
103
|
-
* What the operator asked for. `CELILO_HOOK_JAIL`, and the default is `
|
|
103
|
+
* What the operator asked for. `CELILO_HOOK_JAIL`, and the default is `off`
|
|
104
|
+
* (peba's ruling on ce-rez7; `resolveJailPolicy`'s doc comment below has the
|
|
105
|
+
* reversal trigger).
|
|
104
106
|
*
|
|
105
107
|
* `required` is D8's end state, reached as an explicit act once stage 2 has
|
|
106
108
|
* been proven on a host: an unavailable jail becomes a hard failure rather
|
|
@@ -433,21 +435,37 @@ export function jailPolicy(): JailPolicy {
|
|
|
433
435
|
}
|
|
434
436
|
|
|
435
437
|
/** Where the effective jail policy came from. Rendered by `system doctor` (D4). */
|
|
436
|
-
export type JailPolicySource = 'env' | 'config' | 'default';
|
|
438
|
+
export type JailPolicySource = 'env' | 'module' | 'config' | 'default';
|
|
439
|
+
|
|
440
|
+
/** How much jailing a policy does. Lower is weaker (per-module-jail-policy). */
|
|
441
|
+
export const POLICY_STRENGTH: Record<JailPolicy, number> = { off: 0, auto: 1, required: 2 };
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* Whether the module's own policy jails LESS than the system's would — the
|
|
445
|
+
* definition of an exemption (per-module-jail-policy task 3.1-3.3).
|
|
446
|
+
*
|
|
447
|
+
* Shared by `module jail`'s weakening interview, `module list`'s marker,
|
|
448
|
+
* `system doctor`'s count and `system audit`'s category, so the four
|
|
449
|
+
* surfaces cannot disagree about what an exemption is.
|
|
450
|
+
*/
|
|
451
|
+
export function isWeakerJailPolicy(modulePolicy: JailPolicy, systemPolicy: JailPolicy): boolean {
|
|
452
|
+
return POLICY_STRENGTH[modulePolicy] < POLICY_STRENGTH[systemPolicy];
|
|
453
|
+
}
|
|
437
454
|
|
|
438
455
|
/**
|
|
439
|
-
* Apply
|
|
440
|
-
*
|
|
441
|
-
*
|
|
442
|
-
*
|
|
456
|
+
* Apply the precedence to the four sources (per-module-jail-policy task 1.2):
|
|
457
|
+
* env wins, then the module's own recorded policy, then stored system
|
|
458
|
+
* config, then the default, which is `off` (peba's rulings on ce-rez7 and
|
|
459
|
+
* ce-8832; see openspec/changes/hook-jail-config-surface/design.md and
|
|
460
|
+
* openspec/changes/per-module-jail-policy/ for the fixed chain).
|
|
443
461
|
*
|
|
444
462
|
* Pure, so both consumers (executor.ts and system-doctor.ts) can fetch the
|
|
445
|
-
* stored
|
|
463
|
+
* stored rows themselves and stay testable without a database.
|
|
446
464
|
*
|
|
447
465
|
* The empty-string env value is treated as unset, matching `jailPolicy()`'s
|
|
448
466
|
* own handling — one rule for "what counts as set", not a second one here.
|
|
449
467
|
*
|
|
450
|
-
* The
|
|
468
|
+
* The stored paths are deliberately stricter than the env path: a stored
|
|
451
469
|
* empty string THROWS (peba's ruling on ce-8832). The set-time pattern
|
|
452
470
|
* `^(auto|off|required)$` rejects `''`, so a stored one can only arrive via
|
|
453
471
|
* a restore from file or a hand edit to the DB — exactly the untrusted
|
|
@@ -456,9 +474,15 @@ export type JailPolicySource = 'env' | 'config' | 'default';
|
|
|
456
474
|
* closed is the whole point. An empty CELILO_HOOK_JAIL stays "not set",
|
|
457
475
|
* because `jailPolicy()` relies on that and slice 1's safety rests on the
|
|
458
476
|
* two resolvers agreeing.
|
|
477
|
+
*
|
|
478
|
+
* The module row follows the same throw rule as the system key (task 1.3).
|
|
479
|
+
* `undefined` is the everyday case — no row, follow the system — and a row
|
|
480
|
+
* keyed to a module that was removed is impossible by schema: the FK
|
|
481
|
+
* cascades the delete.
|
|
459
482
|
*/
|
|
460
483
|
export function resolveJailPolicy(
|
|
461
484
|
envValue: string | undefined,
|
|
485
|
+
moduleValue: string | undefined,
|
|
462
486
|
configValue: string | undefined,
|
|
463
487
|
): { policy: JailPolicy; source: JailPolicySource } {
|
|
464
488
|
if (envValue !== undefined && envValue !== '') {
|
|
@@ -469,6 +493,14 @@ export function resolveJailPolicy(
|
|
|
469
493
|
`CELILO_HOOK_JAIL='${envValue}' is not a hook jail policy. Use 'auto' (jail when a backend is available), 'required' (an unavailable jail is a hard failure), or 'off'.`,
|
|
470
494
|
);
|
|
471
495
|
}
|
|
496
|
+
if (moduleValue !== undefined) {
|
|
497
|
+
if (moduleValue === 'auto' || moduleValue === 'off' || moduleValue === 'required') {
|
|
498
|
+
return { policy: moduleValue, source: 'module' };
|
|
499
|
+
}
|
|
500
|
+
throw new Error(
|
|
501
|
+
`the module's jail policy='${moduleValue}' is not a hook jail policy. Use 'auto' (jail when a backend is available), 'required' (an unavailable jail is a hard failure), or 'off'.`,
|
|
502
|
+
);
|
|
503
|
+
}
|
|
472
504
|
if (configValue !== undefined) {
|
|
473
505
|
if (configValue === 'auto' || configValue === 'off' || configValue === 'required') {
|
|
474
506
|
return { policy: configValue, source: 'config' };
|
|
@@ -620,9 +652,14 @@ function realpathOrSelf(path: string): string {
|
|
|
620
652
|
* Why an `off` policy runs unjailed, shared with `celilo system doctor` (task
|
|
621
653
|
* 4.6) so the sentence the operator reads there is the sentence the record
|
|
622
654
|
* carries.
|
|
655
|
+
*
|
|
656
|
+
* It names no source. Since hook-jail-config-surface there are three (the
|
|
657
|
+
* environment variable, the stored `hooks.jail_policy` row, and the `off`
|
|
658
|
+
* default), and this sentence is rendered for all of them. The doctor prints
|
|
659
|
+
* the source on its own line above this one; the record carries it as state.
|
|
623
660
|
*/
|
|
624
661
|
export const JAIL_OFF_REASON =
|
|
625
|
-
|
|
662
|
+
"The hook jail policy is 'off', so hooks run unjailed on this host. Set hooks.jail_policy to 'auto' or 'required' to jail them.";
|
|
626
663
|
|
|
627
664
|
export function planJailedSpawn(
|
|
628
665
|
cmd: readonly string[],
|
|
@@ -647,7 +684,7 @@ export function planJailedSpawn(
|
|
|
647
684
|
: 'This invocation has no module tree to jail, so there is no mount set to enforce.';
|
|
648
685
|
if (policy === 'required') {
|
|
649
686
|
throw new Error(
|
|
650
|
-
`
|
|
687
|
+
`The hook jail policy is 'required' and no hook jail is available. ${reason ?? ''}`.trim(),
|
|
651
688
|
);
|
|
652
689
|
}
|
|
653
690
|
return { cmd, mode: 'unjailed', backend: availability.backend, reason, skipped: [] };
|
|
@@ -173,6 +173,19 @@ export const V1_HOOKS: ContractHooks = {
|
|
|
173
173
|
* mount-set derivation walking the declared inputs could not see it.
|
|
174
174
|
*/
|
|
175
175
|
system_state_root: { required: false, path: { access: 'read' } },
|
|
176
|
+
/**
|
|
177
|
+
* The management-host inventory as a JSON array, staged by
|
|
178
|
+
* `backup-create.ts` from `listMachines()` under the same
|
|
179
|
+
* cross_module_read privilege as `system_state_root`. Not a path: the
|
|
180
|
+
* hook reads the value, not a file, so it carries no mount-set entry.
|
|
181
|
+
*
|
|
182
|
+
* It replaces on_backup's `celilo machine list --json` spawn, which the
|
|
183
|
+
* hook jail kills (no celilo binary, celilo#1225) and which never
|
|
184
|
+
* actually produced JSON before that — the CLI printed its human report,
|
|
185
|
+
* the hook's JSON.parse failed, and the catch wrote [] into every
|
|
186
|
+
* envelope (ce-y0we).
|
|
187
|
+
*/
|
|
188
|
+
machine_pool: { required: false },
|
|
176
189
|
},
|
|
177
190
|
outputs: {
|
|
178
191
|
artifact_count: { required: true },
|
|
@@ -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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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,51 +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
362
|
{
|
|
348
363
|
module: 'dnsmasq-dhcp',
|
|
349
364
|
file: 'scripts/dhcp-server-functions.ts',
|
|
350
365
|
matches: 1,
|
|
351
366
|
reason:
|
|
352
|
-
'hook
|
|
353
|
-
},
|
|
354
|
-
{
|
|
355
|
-
module: 'forgejo',
|
|
356
|
-
file: 'scripts/on-consumer-removed.ts',
|
|
357
|
-
matches: 1,
|
|
358
|
-
reason: 'hook-owned-state 5.5 (runner_registrations, Group B3)',
|
|
367
|
+
'capability provider, no hook entry imports it — ce-mqay (jail does not constrain it; conversion is dedup, not breakage)',
|
|
359
368
|
},
|
|
360
369
|
{
|
|
361
|
-
module: '
|
|
362
|
-
file: 'scripts/
|
|
363
|
-
matches:
|
|
364
|
-
reason: 'hook-owned-state 5.5 (secret set)',
|
|
365
|
-
},
|
|
366
|
-
{
|
|
367
|
-
module: 'forgejo',
|
|
368
|
-
file: 'scripts/source-forge-functions.ts',
|
|
369
|
-
matches: 1,
|
|
370
|
-
reason:
|
|
371
|
-
'hook-owned-state 5.5 shape (self-config write); not in its 10-site table — reconcile there',
|
|
372
|
-
},
|
|
373
|
-
{
|
|
374
|
-
module: 'generic-cpanel-hosting-provider',
|
|
375
|
-
file: 'scripts/on-consumer-removed.ts',
|
|
376
|
-
matches: 1,
|
|
377
|
-
reason: 'hook-owned-state 5.5 (publications, Group B4)',
|
|
378
|
-
},
|
|
379
|
-
{
|
|
380
|
-
module: 'generic-cpanel-hosting-provider',
|
|
381
|
-
file: 'scripts/publish-functions.ts',
|
|
382
|
-
matches: 1,
|
|
370
|
+
module: 'iptables',
|
|
371
|
+
file: 'scripts/firewall-functions.ts',
|
|
372
|
+
matches: 3,
|
|
383
373
|
reason:
|
|
384
|
-
'hook-
|
|
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',
|
|
385
375
|
},
|
|
386
376
|
];
|
|
387
377
|
|
|
@@ -393,19 +383,13 @@ export const JAILED_CLI_SPAWN_DEBT: readonly PolicyDebtEntry[] = [
|
|
|
393
383
|
* same guarded rot: when a site converts, the count drops, the debt test goes
|
|
394
384
|
* red, and the entry must be deleted in the same change.
|
|
395
385
|
*
|
|
396
|
-
*
|
|
397
|
-
*
|
|
398
|
-
*
|
|
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.
|
|
399
391
|
*/
|
|
400
|
-
export const SWALLOWED_REFUSAL_DEBT: readonly PolicyDebtEntry[] = [
|
|
401
|
-
{
|
|
402
|
-
module: 'celilo-mgmt',
|
|
403
|
-
file: 'scripts/on_backup.ts',
|
|
404
|
-
matches: 1,
|
|
405
|
-
reason:
|
|
406
|
-
'hook-owned-state 5.5 / survey class G: machine list becomes a typed-absent machine-pool read',
|
|
407
|
-
},
|
|
408
|
-
];
|
|
392
|
+
export const SWALLOWED_REFUSAL_DEBT: readonly PolicyDebtEntry[] = [];
|
|
409
393
|
|
|
410
394
|
/**
|
|
411
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
|
-
|
|
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:
|
|
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
|
|
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,13 +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
|
-
//
|
|
229
|
-
//
|
|
230
|
-
//
|
|
231
|
-
//
|
|
232
|
-
|
|
233
|
-
// exists to stop.
|
|
234
|
-
expect(sites).toEqual(['modules/celilo-mgmt/scripts/on_backup.ts']);
|
|
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([]);
|
|
235
233
|
});
|
|
236
234
|
});
|
|
237
235
|
|
|
@@ -255,10 +253,12 @@ describe('recurrence gate: the swallow debt list is honest', () => {
|
|
|
255
253
|
}
|
|
256
254
|
});
|
|
257
255
|
|
|
258
|
-
test('the debt list is
|
|
259
|
-
// The list held three entries
|
|
260
|
-
//
|
|
261
|
-
// 2026-09-
|
|
262
|
-
|
|
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([]);
|
|
263
263
|
});
|
|
264
264
|
});
|
|
@@ -48,14 +48,14 @@ describe('RegistryClient constructor', () => {
|
|
|
48
48
|
|
|
49
49
|
afterEach(() => {
|
|
50
50
|
if (origEnv === undefined) {
|
|
51
|
-
process.env.CELILO_REGISTRY_URL
|
|
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
|
|
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
|
|
39
|
+
delete process.env.CELILO_MASTER_KEY_PATH;
|
|
40
40
|
});
|
|
41
41
|
|
|
42
42
|
test('stores and retrieves system secret', async () => {
|
|
@@ -116,6 +116,10 @@ describe('severityForItemStatus', () => {
|
|
|
116
116
|
expect(severityForItemStatus('fail', 'critical')).toBe('critical');
|
|
117
117
|
expect(severityForItemStatus('fail', 'warning')).toBe('warning');
|
|
118
118
|
});
|
|
119
|
+
|
|
120
|
+
test('skip produces no alert — a check that could not run is not a failure', () => {
|
|
121
|
+
expect(severityForItemStatus('skip', 'critical')).toBeNull();
|
|
122
|
+
});
|
|
119
123
|
});
|
|
120
124
|
|
|
121
125
|
describe('failingKeysFromHealthItems', () => {
|
|
@@ -134,14 +134,24 @@ export function parentModuleKey(key: string): string | null {
|
|
|
134
134
|
* and never pages. That gives module authors somewhere to put "cert expires in
|
|
135
135
|
* 20 days" without anyone having to decide whether it is a 3am problem.
|
|
136
136
|
*
|
|
137
|
+
* `skip` produces no alert either: a check that could not perform its probe
|
|
138
|
+
* is not a failure. Note the honest cost — a previously-failing key whose
|
|
139
|
+
* item now reports skip leaves this run's failing set, so the reconciler
|
|
140
|
+
* reads "recovered" off a run that measured nothing. That is the same hazard
|
|
141
|
+
* class design D5 handles at run level (a run that could not execute is the
|
|
142
|
+
* caller's branch); there is no per-item sticky-key mechanism here, so a
|
|
143
|
+
* module whose credential disappeared needs the audit's `unmeasured` finding
|
|
144
|
+
* to be seen (celilo#1326).
|
|
145
|
+
*
|
|
137
146
|
* See design D6.
|
|
138
147
|
*/
|
|
139
148
|
export function severityForItemStatus(
|
|
140
|
-
itemStatus: 'pass' | 'warn' | 'fail',
|
|
149
|
+
itemStatus: 'pass' | 'warn' | 'fail' | 'skip',
|
|
141
150
|
monitorSeverity: AlertSeverity,
|
|
142
151
|
): AlertSeverity | null {
|
|
143
152
|
if (itemStatus === 'pass') return null;
|
|
144
153
|
if (itemStatus === 'warn') return 'warning';
|
|
154
|
+
if (itemStatus === 'skip') return null;
|
|
145
155
|
return monitorSeverity;
|
|
146
156
|
}
|
|
147
157
|
|
|
@@ -155,7 +165,7 @@ export interface FailingKey {
|
|
|
155
165
|
|
|
156
166
|
export interface HealthCheckItemLike {
|
|
157
167
|
name: string;
|
|
158
|
-
status: 'pass' | 'warn' | 'fail';
|
|
168
|
+
status: 'pass' | 'warn' | 'fail' | 'skip';
|
|
159
169
|
message: string;
|
|
160
170
|
details?: string;
|
|
161
171
|
}
|