@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.
Files changed (112) 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/src/__integration__/container-services-cli.integration.test.ts +1 -1
  9. package/src/api/sessions.test.ts +3 -3
  10. package/src/api-clients/proxmox.ts +10 -7
  11. package/src/cli/backup-rename.test.ts +3 -3
  12. package/src/cli/cli.test.ts +1 -1
  13. package/src/cli/commands/events.test.ts +2 -2
  14. package/src/cli/commands/firewall-interface-list.test.ts +2 -2
  15. package/src/cli/commands/machine-list.test.ts +57 -0
  16. package/src/cli/commands/machine-list.ts +35 -4
  17. package/src/cli/commands/module-health.ts +1 -0
  18. package/src/cli/commands/module-import-registry.test.ts +1 -1
  19. package/src/cli/commands/module-jail.test.ts +242 -0
  20. package/src/cli/commands/module-jail.ts +227 -0
  21. package/src/cli/commands/module-list-jail.test.ts +136 -0
  22. package/src/cli/commands/module-list.ts +30 -3
  23. package/src/cli/commands/module-publish.test.ts +6 -6
  24. package/src/cli/commands/module-update.test.ts +22 -7
  25. package/src/cli/commands/module-update.ts +4 -1
  26. package/src/cli/commands/module-upgrade.test.ts +115 -1
  27. package/src/cli/commands/module-upgrade.ts +23 -1
  28. package/src/cli/commands/module-verify.test.ts +1 -1
  29. package/src/cli/commands/publish/execute.ts +4 -1
  30. package/src/cli/commands/publish/index.ts +11 -1
  31. package/src/cli/commands/publish/module-registry.test.ts +24 -1
  32. package/src/cli/commands/publish/module-registry.ts +23 -4
  33. package/src/cli/commands/publish/plan.ts +1 -1
  34. package/src/cli/commands/publish/types.ts +7 -0
  35. package/src/cli/commands/registry-owner.test.ts +1 -1
  36. package/src/cli/commands/registry-token.test.ts +1 -1
  37. package/src/cli/commands/system-audit.ts +20 -6
  38. package/src/cli/commands/system-doctor-remediation-gate.test.ts +295 -0
  39. package/src/cli/commands/system-doctor.test.ts +76 -10
  40. package/src/cli/commands/system-doctor.ts +34 -3
  41. package/src/cli/commands/system-init-deprecation.test.ts +1 -1
  42. package/src/cli/commands/system-update.ts +5 -0
  43. package/src/cli/completion.ts +8 -2
  44. package/src/cli/flag-surface-gate.test.ts +279 -0
  45. package/src/cli/index.ts +68 -2
  46. package/src/cli/parser.test.ts +37 -1
  47. package/src/cli/restore-command.test.ts +3 -3
  48. package/src/cli/restore-migration-failure.test.ts +2 -2
  49. package/src/cli/tui/audit-state.ts +2 -0
  50. package/src/config/paths.test.ts +19 -19
  51. package/src/db/client.test.ts +46 -1
  52. package/src/db/client.ts +26 -0
  53. package/src/db/schema.ts +63 -0
  54. package/src/hooks/capability-loader-firewall.test.ts +9 -1
  55. package/src/hooks/capability-loader.ts +8 -0
  56. package/src/hooks/executor.test.ts +86 -12
  57. package/src/hooks/executor.ts +31 -1
  58. package/src/hooks/hook-jail-unreachability.test.ts +17 -18
  59. package/src/hooks/hook-trespass.test.ts +9 -2
  60. package/src/hooks/jail.test.ts +105 -17
  61. package/src/hooks/jail.ts +47 -10
  62. package/src/manifest/contracts/v1.ts +13 -0
  63. package/src/policy/module-script-scan.ts +52 -68
  64. package/src/policy/no-hand-built-ssh.test.ts +5 -1
  65. package/src/policy/no-swallowed-refusal.test.ts +14 -14
  66. package/src/registry/client.test.ts +2 -2
  67. package/src/secrets/storage.test.ts +1 -1
  68. package/src/services/alerting/keys.test.ts +4 -0
  69. package/src/services/alerting/keys.ts +12 -2
  70. package/src/services/audit/health.test.ts +97 -2
  71. package/src/services/audit/health.ts +64 -2
  72. package/src/services/audit/index.test.ts +22 -0
  73. package/src/services/audit/index.ts +8 -2
  74. package/src/services/audit/jail-exemptions.test.ts +42 -0
  75. package/src/services/audit/jail-exemptions.ts +44 -0
  76. package/src/services/audit/module-integrity.test.ts +23 -1
  77. package/src/services/audit/module-integrity.ts +7 -2
  78. package/src/services/audit/types.ts +2 -1
  79. package/src/services/backup-create.ts +9 -0
  80. package/src/services/backup-envelope-roundtrip.test.ts +1 -1
  81. package/src/services/backup-in-flight-refusal.test.ts +1 -1
  82. package/src/services/build-bus/hook-dispatch-mgmt.test.ts +48 -0
  83. package/src/services/build-bus/hook-dispatcher.ts +49 -1
  84. package/src/services/bus-ensure-flow.test.ts +1 -1
  85. package/src/services/bus-interview-park.test.ts +2 -2
  86. package/src/services/bus-interview.test.ts +2 -2
  87. package/src/services/bus-secret-flow.test.ts +1 -1
  88. package/src/services/celilo-events.test.ts +1 -1
  89. package/src/services/celilo-mgmt-hooks.test.ts +23 -5
  90. package/src/services/container-service.test.ts +1 -1
  91. package/src/services/cross-module-read.test.ts +2 -2
  92. package/src/services/deploy-preflight.ts +7 -0
  93. package/src/services/deployed-systems.ts +1 -1
  94. package/src/services/fleet-checks.test.ts +123 -4
  95. package/src/services/fleet-checks.ts +176 -3
  96. package/src/services/health-runner.test.ts +87 -2
  97. package/src/services/health-runner.ts +40 -11
  98. package/src/services/infrastructure-selector.test.ts +1 -1
  99. package/src/services/jail-exemptions.test.ts +125 -0
  100. package/src/services/jail-exemptions.ts +81 -0
  101. package/src/services/machine-pool.test.ts +1 -1
  102. package/src/services/module-deploy.ts +12 -4
  103. package/src/services/module-subscriptions.test.ts +2 -2
  104. package/src/services/network-discovery.test.ts +64 -1
  105. package/src/services/network-discovery.ts +30 -5
  106. package/src/services/responder-probe.test.ts +1 -1
  107. package/src/services/restore-from-file.test.ts +4 -4
  108. package/src/services/restore-preflight.test.ts +1 -1
  109. package/src/services/ssh-key-manager.test.ts +2 -2
  110. package/src/services/system-state-stage.test.ts +2 -2
  111. package/src/services/update/orchestrator.test.ts +2 -0
  112. package/src/test-utils/bus-responder.ts +1 -1
@@ -3,7 +3,8 @@ import { afterEach, describe, expect, test } from 'bun:test';
3
3
  import { rmSync } from 'node:fs';
4
4
  import { tmpdir } from 'node:os';
5
5
  import { join } from 'node:path';
6
- import { createDbClient } from './client';
6
+ import { resetTestDbPath } from '../test-utils/db-path';
7
+ import { closeDb, createDbClient, getDb } from './client';
7
8
 
8
9
  // #798: a command that opened the db while a deploy still held it died on
9
10
  // SQLITE_BUSY immediately, because the connection carried no busy timeout.
@@ -61,3 +62,47 @@ describe('createDbClient concurrency', () => {
61
62
  }
62
63
  });
63
64
  });
65
+
66
+ /**
67
+ * The tripwire from celilo#1343: under bun test, a default-path open must be
68
+ * refused instead of landing on the operator's real database. The scratch-DB
69
+ * preload (apps/celilo/test-preload.ts) arms CELILO_DB_PATH only when bun
70
+ * runs from apps/celilo/, so every one of these tests strips the redirects
71
+ * itself to reproduce the out-of-scope state.
72
+ */
73
+ describe('createDbClient operator-database tripwire (celilo#1343)', () => {
74
+ afterEach(() => {
75
+ closeDb();
76
+ resetTestDbPath();
77
+ delete process.env.CELILO_DATA_DIR;
78
+ delete process.env.ENVIRONMENT;
79
+ });
80
+
81
+ test('default-path open under bun test throws, naming the fix', () => {
82
+ delete process.env.CELILO_DB_PATH;
83
+ delete process.env.CELILO_DATA_DIR;
84
+ delete process.env.ENVIRONMENT;
85
+
86
+ expect(() => getDb()).toThrow(/CELILO_DB_PATH/);
87
+ });
88
+
89
+ test('CELILO_DATA_DIR redirect keeps the default-path open allowed', () => {
90
+ delete process.env.CELILO_DB_PATH;
91
+ delete process.env.ENVIRONMENT;
92
+ process.env.CELILO_DATA_DIR = join(tmpdir(), 'celilo-tripwire-redirect');
93
+
94
+ const db = createDbClient();
95
+ db.$client.close();
96
+ });
97
+
98
+ test('explicit config path bypasses the tripwire', () => {
99
+ delete process.env.CELILO_DB_PATH;
100
+ delete process.env.CELILO_DATA_DIR;
101
+ delete process.env.ENVIRONMENT;
102
+
103
+ const db = createDbClient({
104
+ path: join(tmpdir(), 'celilo-tripwire-explicit', 'celilo.db'),
105
+ });
106
+ db.$client.close();
107
+ });
108
+ });
package/src/db/client.ts CHANGED
@@ -49,6 +49,32 @@ export function createDbClient(config?: Partial<DatabaseConfig>) {
49
49
  const dbPath = config?.path ?? getDbPath();
50
50
  const readonly = config?.readonly ?? false;
51
51
 
52
+ // Tripwire against opening the operator's real database from a test
53
+ // (celilo#1343). The scratch-DB preload in apps/celilo/test-preload.ts only
54
+ // arms when bun runs from apps/celilo/ — bun reads the bunfig at the working
55
+ // directory — so the same suite run from the repo root reaches this function
56
+ // with every override unset and would open, and migrate, the operator's
57
+ // ~/Library/.../celilo.db. The condition below is exactly that resolution:
58
+ // under bun test, with every redirect unset, getDbPath() falls through to
59
+ // the platform data directory. A run that is properly scoped (preload armed,
60
+ // CELILO_DATA_DIR redirected, or an explicit config.path) never satisfies
61
+ // it and pays one string comparison.
62
+ if (
63
+ !config?.path &&
64
+ process.env.NODE_ENV === 'test' &&
65
+ !process.env.CELILO_DB_PATH &&
66
+ !process.env.CELILO_DATA_DIR &&
67
+ process.env.ENVIRONMENT !== 'dev'
68
+ ) {
69
+ throw new Error(
70
+ 'Refusing to open the celilo database under bun test with no database path set ' +
71
+ '(celilo#1343): the scratch-DB preload in apps/celilo/test-preload.ts only arms ' +
72
+ "when bun runs from apps/celilo/, so this default path is the operator's real " +
73
+ 'celilo.db. Run tests from apps/celilo/, or set CELILO_DB_PATH (or ' +
74
+ 'CELILO_DATA_DIR) to a test-scoped path.',
75
+ );
76
+ }
77
+
52
78
  // bun:sqlite's `create: true` makes the file but not the parent directory.
53
79
  // First-run after a fresh install hits this — without recursive mkdir we
54
80
  // get SQLITE_CANTOPEN before init can write its config.
package/src/db/schema.ts CHANGED
@@ -212,6 +212,45 @@ export const capabilityBindings = sqliteTable(
212
212
  }),
213
213
  );
214
214
 
215
+ /**
216
+ * One build-bus `on_upstream_publish` hook execution (services/build-bus/hook-dispatcher.ts).
217
+ *
218
+ * The self-update path had no durable record: outcomes were console output
219
+ * inside the receiver daemon, so a CLI that never upgraded looked like a
220
+ * healthy fleet (celilo#1304). One row per (event, module, hook) run — the
221
+ * newest row answers "did the self-update for @celilo/cli@X fire and succeed?".
222
+ *
223
+ * `exit_code` is NULL when the script never ran to an exit (spawn failure).
224
+ * Not an event-bus delivery: hooks run from the receiver's in-process
225
+ * dispatcher, which no `deliveries` row was ever written for.
226
+ *
227
+ * @owner celilo — build-bus self-update audit trail (celilo#1304)
228
+ */
229
+ export const buildBusHookRuns = sqliteTable(
230
+ 'build_bus_hook_runs',
231
+ {
232
+ id: integer('id').primaryKey({ autoIncrement: true }),
233
+ /** The PublishEvent's dedup id — joins back to the bus's `build-bus.publish` event. */
234
+ eventId: text('event_id').notNull(),
235
+ packageName: text('package_name').notNull(),
236
+ packageVersion: text('package_version').notNull(),
237
+ tag: text('tag').notNull(),
238
+ moduleId: text('module_id').notNull(),
239
+ hookName: text('hook_name').notNull(),
240
+ scriptPath: text('script_path').notNull(),
241
+ /** Null means the script never reached an exit (spawn failure). */
242
+ exitCode: integer('exit_code'),
243
+ timedOut: integer('timed_out', { mode: 'boolean' }).notNull().default(false),
244
+ durationMs: integer('duration_ms').notNull(),
245
+ stdoutTail: text('stdout_tail'),
246
+ stderrTail: text('stderr_tail'),
247
+ ranAt: integer('created_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
248
+ },
249
+ (table) => ({
250
+ ranAtLookup: index('build_bus_hook_runs_created_at_idx').on(table.ranAt),
251
+ }),
252
+ );
253
+
215
254
  /**
216
255
  * Capability secrets table - stores encrypted secrets owned by capabilities
217
256
  * Values are encrypted with AES-256-GCM using master key
@@ -310,6 +349,28 @@ export const moduleIntegrity = sqliteTable('module_integrity', {
310
349
  updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
311
350
  });
312
351
 
352
+ /**
353
+ * Per-module hook jail policy — the operator's row in the four-step
354
+ * precedence (per-module-jail-policy task 1.1), ranked between the
355
+ * `CELILO_HOOK_JAIL` environment variable and the system-wide
356
+ * `hooks.jail_policy` key.
357
+ *
358
+ * One row per module; an absent row means "follow the system", so there is
359
+ * no default column to get wrong. The value is validated at set time by the
360
+ * `module jail` verb and re-validated at read time by `resolveJailPolicy`,
361
+ * which throws rather than coercing — a restored or hand-edited row outside
362
+ * the accepted set must fail loudly, the same rule the system key follows
363
+ * (peba's ruling on ce-8832).
364
+ * @owner celilo — operator-set per-module jail policy
365
+ */
366
+ export const moduleJailPolicies = sqliteTable('module_jail_policies', {
367
+ moduleId: text('module_id')
368
+ .primaryKey()
369
+ .references(() => modules.id, { onDelete: 'cascade' }),
370
+ policy: text('policy').$type<'auto' | 'off' | 'required'>().notNull(),
371
+ updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
372
+ });
373
+
313
374
  /**
314
375
  * Lifecycle states an instance moves through.
315
376
  *
@@ -1579,3 +1640,5 @@ export type NotificationDelivery = typeof notificationDeliveries.$inferSelect;
1579
1640
  export type NewNotificationDelivery = typeof notificationDeliveries.$inferInsert;
1580
1641
  export type CapabilityBinding = typeof capabilityBindings.$inferSelect;
1581
1642
  export type NewCapabilityBinding = typeof capabilityBindings.$inferInsert;
1643
+ export type BuildBusHookRun = typeof buildBusHookRuns.$inferSelect;
1644
+ export type NewBuildBusHookRun = typeof buildBusHookRuns.$inferInsert;
@@ -293,6 +293,8 @@ describe('Firewall Chain Building', () => {
293
293
  upsertModuleConfig(db, 'iptables', 'nat_ip', '192.168.0.253');
294
294
  upsertModuleConfig(db, 'iptables', 'default_route_zone', 'isp-transit');
295
295
  upsertModuleConfig(db, 'iptables', 'isolate_transit_network', true);
296
+ upsertModuleConfig(db, 'iptables', 'interface_baseline', 'eth0,eth2');
297
+ upsertModuleConfig(db, 'iptables', 'interface_zone_map', 'eth0=isp-transit,eth2=internal');
296
298
 
297
299
  const config = await loadedConfig();
298
300
  expect(config.defaultRouteZone).toBe('isp-transit');
@@ -300,6 +302,8 @@ describe('Firewall Chain Building', () => {
300
302
  // preserves the manifest-declared type, and the module tests
301
303
  // `state.isolateTransitNetwork && ...` — where 'false' would be truthy.
302
304
  expect(config.isolateTransitNetwork).toBe(true);
305
+ expect(config.interfaceBaseline).toEqual(['eth0', 'eth2']);
306
+ expect(config.interfaceZoneMap).toBe('eth0=isp-transit,eth2=internal');
303
307
  });
304
308
 
305
309
  test('single provider: the recorded interface_baseline is forwarded as a parsed list', async () => {
@@ -358,13 +362,15 @@ describe('Firewall Chain Building', () => {
358
362
  upsertModuleConfig(db, 'iptables', 'default_route_zone', 'internal');
359
363
  upsertModuleConfig(db, 'iptables', 'isolate_transit_network', true);
360
364
  upsertModuleConfig(db, 'iptables', 'interface_baseline', 'eth0,eth1');
365
+ upsertModuleConfig(db, 'iptables', 'interface_zone_map', 'eth0=isp-transit,eth1=internal');
361
366
 
362
367
  const config = await loadedConfig();
363
368
  expect(config.defaultRouteZone).toBe('internal');
364
369
  expect(config.isolateTransitNetwork).toBe(true);
365
- // The downstream construction site already forwarded this; pinned so the
370
+ // The downstream construction site already forwarded these; pinned so the
366
371
  // two sites cannot drift apart again.
367
372
  expect(config.interfaceBaseline).toEqual(['eth0', 'eth1']);
373
+ expect(config.interfaceZoneMap).toBe('eth0=isp-transit,eth1=internal');
368
374
  });
369
375
 
370
376
  test('unset settings arrive undefined, not as a wrong default', async () => {
@@ -378,6 +384,8 @@ describe('Firewall Chain Building', () => {
378
384
  // would be silently overridden by a stale copy here.
379
385
  expect(config.defaultRouteZone).toBeUndefined();
380
386
  expect(config.isolateTransitNetwork).toBeUndefined();
387
+ expect(config.interfaceBaseline).toBeUndefined();
388
+ expect(config.interfaceZoneMap).toBeUndefined();
381
389
  });
382
390
  });
383
391
  });
@@ -838,6 +838,10 @@ function buildCapabilityInterface(
838
838
  // baseline was recorded but never read back, so every converge stayed
839
839
  // in onboarding mode and refused instead of isolating (ce-qzxm).
840
840
  interfaceBaseline: parseInterfaceBaseline(config.interface_baseline),
841
+ // The learned zone-to-leg map, same round trip and the same site that
842
+ // dropped it. The module writes it at the end of a clean converge and
843
+ // reads it back to keep a managed /32 on its declared zone leg.
844
+ interfaceZoneMap: config.interface_zone_map as string | undefined,
841
845
  // Operator settings, forwarded verbatim. `parseStoredConfigValue`
842
846
  // preserves each manifest-declared type, so the boolean arrives as a
843
847
  // boolean and needs no coercion here.
@@ -1361,6 +1365,10 @@ async function buildFirewallChain(
1361
1365
  // Absent means the box has never converged cleanly, so an interface
1362
1366
  // celilo cannot attribute refuses rather than being disabled.
1363
1367
  interfaceBaseline: parseInterfaceBaseline(provConfig.interface_baseline),
1368
+ // The managed-address resolver consults this before it reads the live
1369
+ // address table. Dropping it makes the fallback select the management
1370
+ // (SSH) interface, after which the bad alias can corrupt the learned map.
1371
+ interfaceZoneMap: provConfig.interface_zone_map as string | undefined,
1364
1372
  // Which declared zone carries this firewall's default route, and
1365
1373
  // whether fronted zones may initiate into it. Both are operator
1366
1374
  // settings and both are read HERE or nowhere: the module declares them
@@ -4,7 +4,7 @@ import { tmpdir } from 'node:os';
4
4
  import { join } from 'node:path';
5
5
  import { closeDb, getDb } from '../db/client';
6
6
  import { runMigrations } from '../db/migrate';
7
- import { systemConfig } from '../db/schema';
7
+ import { modules, systemConfig } from '../db/schema';
8
8
  import type { ContractHookSignature } from '../manifest/contracts';
9
9
  import { resetTestDbPath } from '../test-utils/db-path';
10
10
  import {
@@ -347,18 +347,53 @@ describe('Hook Executor', () => {
347
347
  getDb().insert(systemConfig).values({ key: 'hooks.jail_policy', value }).run();
348
348
  };
349
349
 
350
- const runVoidHook = (): Promise<Record<string, unknown>> => {
350
+ // The per-module row is keyed by module id, but the executor is handed
351
+ // the module's tree — the join the production read makes (task 1.4). The
352
+ // path is fake: nothing in this describe touches the filesystem beyond
353
+ // the scratch DB, and `existsSync(scriptPath)` fails before any policy
354
+ // read when it is not fake.
355
+ const moduleTree = (): string => join(dir, 'modules', 'fixture-module');
356
+ const plantModulePolicyRow = (moduleId: string, policy: string): void => {
357
+ getDb()
358
+ .insert(modules)
359
+ .values({
360
+ id: moduleId,
361
+ name: moduleId,
362
+ version: '1.0.0',
363
+ manifestData: {},
364
+ sourcePath: moduleTree(),
365
+ })
366
+ .run();
367
+ // Raw SQL, not the drizzle table, on purpose: the drizzle type only
368
+ // accepts 'auto' | 'off' | 'required', and the bad-value test models a
369
+ // row that arrived past every typed writer — a restore or a hand edit,
370
+ // the untrusted path the resolver throws on (Rule 3.7).
371
+ getDb()
372
+ .$client.prepare('INSERT INTO module_jail_policies (module_id, policy) VALUES (?, ?)')
373
+ .run(moduleId, policy);
374
+ };
375
+
376
+ const runVoidHook = (moduleTree?: string): Promise<Record<string, unknown>> => {
351
377
  const { logger } = createCapturingLogger();
352
- return executeHookScript(join(FIXTURES_DIR, 'void-hook.ts'), {
353
- config: configStore(),
354
- secrets: secretStore(),
355
- systems: [],
356
- logger,
357
- debug: false,
358
- screenshotDir: '/tmp',
359
- stateDir: '/tmp',
360
- capabilities: {},
361
- });
378
+ return executeHookScript(
379
+ join(FIXTURES_DIR, 'void-hook.ts'),
380
+ {
381
+ config: configStore(),
382
+ secrets: secretStore(),
383
+ systems: [],
384
+ logger,
385
+ debug: false,
386
+ screenshotDir: '/tmp',
387
+ stateDir: '/tmp',
388
+ capabilities: {},
389
+ },
390
+ {
391
+ // Absent means the system-config-only path: exactly what the tests
392
+ // above exercise. Present means the executor reads the module's row
393
+ // and resolves through it.
394
+ jail: moduleTree === undefined ? undefined : { modulePath: moduleTree, pathInputs: [] },
395
+ },
396
+ );
362
397
  };
363
398
 
364
399
  beforeEach(async () => {
@@ -413,6 +448,45 @@ describe('Hook Executor', () => {
413
448
  // resolver falls through to the default, which jails nobody.
414
449
  await expect(runVoidHook()).resolves.toBeDefined();
415
450
  });
451
+
452
+ test('the module row beats system config (per-module-jail-policy task 1.4)', async () => {
453
+ // System says jail hard; the module is exempted. The exempted module's
454
+ // hook runs unjailed while every other module of this host would fail —
455
+ // the spec's own "A module runs unjailed while the fleet is jailed".
456
+ plantPolicyRow('required');
457
+ plantModulePolicyRow('exempted-module', 'off');
458
+ await expect(runVoidHook(moduleTree())).resolves.toBeDefined();
459
+ // And the same host still jails a module with no row: `required` with
460
+ // no jail backend is a hard failure, so the system row stays in force.
461
+ // Which surface the failure takes is host-determined, and both prove
462
+ // the hook did not run: on a host with a jail backend it dies at mount
463
+ // planning (the fake module tree does not exist, "cannot be built");
464
+ // on a host with no backend it dies at backend detection ("no hook
465
+ // jail is available"). The point under test is policy precedence, not
466
+ // the surface. (celilo#1342: the CI runner has no bubblewrap, so the
467
+ // mount-planning surface is unreachable there.)
468
+ await expect(runVoidHook(join(dir, 'modules', 'other-module'))).rejects.toThrow(
469
+ /cannot be built|no hook jail is available/,
470
+ );
471
+ });
472
+
473
+ test('a bad per-module row fails the hook instead of running it unjailed', async () => {
474
+ plantModulePolicyRow('broken-module', 'alwayssafe');
475
+ await expect(runVoidHook(moduleTree())).rejects.toThrow(/module's jail policy='alwayssafe'/);
476
+ });
477
+
478
+ test('env beats a conflicting module row', async () => {
479
+ plantModulePolicyRow('exempted-module', 'off');
480
+ process.env.CELILO_HOOK_JAIL = 'required';
481
+ // The point is that the env overrode the module's `off`, so the hook
482
+ // is jailed and fails, not exempted and successful. Which failure it
483
+ // hits is host-determined: mount planning (the fake module tree does
484
+ // not exist) on a host with a jail backend, backend detection on a
485
+ // host without one. Either proves the hook did not run.
486
+ await expect(runVoidHook(moduleTree())).rejects.toThrow(
487
+ /cannot be built|no hook jail is available/,
488
+ );
489
+ });
416
490
  });
417
491
 
418
492
  describe('resolveHookTimeouts', () => {
@@ -366,6 +366,32 @@ function readStoredJailPolicy(): string | undefined {
366
366
  return row?.value;
367
367
  }
368
368
 
369
+ /**
370
+ * The module's own recorded jail policy, or undefined when there is no row
371
+ * (per-module-jail-policy task 1.4). The system-key read stays its own
372
+ * function because the two answers come from different tables.
373
+ *
374
+ * The executor is handed the module's tree, `options.jail.modulePath`, which
375
+ * is `mod.sourcePath` — not its id, which the caller holds. The row is found
376
+ * the way the jail inputs identify the module: by joining `modules` on
377
+ * `source_path`, the exact string the caller passed, so no second identity
378
+ * is invented and none of invokeHook's callers need plumbing. Absent jail
379
+ * inputs (an invocation that cannot be jailed) also means no module identity
380
+ * and no module row to read.
381
+ *
382
+ * A bad stored value is not coerced here — `resolveJailPolicy` throws on it,
383
+ * the same rule the system key follows (peba's ruling on ce-8832).
384
+ */
385
+ function readStoredModuleJailPolicy(modulePath: string | undefined): string | undefined {
386
+ if (!modulePath || !existsSync(getDbPath())) return undefined;
387
+ const row = getDb()
388
+ .$client.prepare(
389
+ 'SELECT p.policy AS policy FROM module_jail_policies p JOIN modules m ON m.id = p.module_id WHERE m.source_path = ?',
390
+ )
391
+ .get(modulePath) as { policy: string } | undefined;
392
+ return row?.policy;
393
+ }
394
+
369
395
  /**
370
396
  * Execute a hook script
371
397
  *
@@ -430,7 +456,11 @@ export async function executeHookScript(
430
456
  [process.execPath, HOOK_RUNNER_SPAWN_PATH],
431
457
  mountSet,
432
458
  detectJailBackend(),
433
- resolveJailPolicy(process.env.CELILO_HOOK_JAIL, readStoredJailPolicy()).policy,
459
+ resolveJailPolicy(
460
+ process.env.CELILO_HOOK_JAIL,
461
+ readStoredModuleJailPolicy(options.jail?.modulePath),
462
+ readStoredJailPolicy(),
463
+ ).policy,
434
464
  );
435
465
  reportJail(jail, logger);
436
466
  // Only a real module hook says anything about whether this HOST jails. An
@@ -33,7 +33,7 @@ import { join, resolve } from 'node:path';
33
33
  /** Uniquely named so it never collides with a real key on a jailed dev box. */
34
34
  const PLANTED_KEY_NAME = 'id_celilo_jail_probe';
35
35
  import { executeHookScript } from './executor';
36
- import { detectJailBackend, jailPolicy } from './jail';
36
+ import { detectJailBackend } from './jail';
37
37
  import { createCapturingLogger } from './logger';
38
38
  import { configStore, secretStore } from './test-fixtures/store-backed';
39
39
  import type { HookContext } from './types';
@@ -46,12 +46,15 @@ interface Probe {
46
46
  }
47
47
 
48
48
  const availability = detectJailBackend();
49
- // A host with a backend is not a host that JAILS: `CELILO_HOOK_JAIL=off` is an
50
- // operator switch and the executor honours it, so without this the suite runs
51
- // its assertions against a deliberately unjailed hook and reports the jail
52
- // broken. Only reachable since macOS gained a backend (task 4.8) — before that
53
- // every Mac skipped for want of one and the hole never showed.
54
- const jailed = availability.backend !== 'none' && jailPolicy() !== 'off';
49
+ // Gated on the BACKEND alone, not on `jailPolicy()`. The ambient policy is an
50
+ // operator switch (`CELILO_HOOK_JAIL=off` is the default), and reading it here
51
+ // made the suite skip on every host with the default — measuring nothing, on
52
+ // any platform, forever. The suite does not need the operator's blessing: it
53
+ // sets the policy to `required` inside `runProbe()` and restores it after, so
54
+ // the jail under test is this suite's own act, deterministic wherever a
55
+ // backend exists. (celilo#1359: since the default became `off`, all three
56
+ // jailed suites skipped everywhere.)
57
+ const jailed = availability.backend !== 'none';
55
58
 
56
59
  interface Rig {
57
60
  outputs: {
@@ -208,18 +211,14 @@ describe.skipIf(!jailed)(`the jail is real (backend: ${availability.backend})`,
208
211
  describe.skipIf(jailed)('this run is not jailed', () => {
209
212
  test('says so, rather than reporting a pass it did not earn', () => {
210
213
  // Not an assertion about the product. It is the line that stops a green
211
- // run on an unjailed host reading as "the jail was proven".
212
- const why =
213
- availability.backend === 'none'
214
- ? (availability.reason ?? 'no backend')
215
- : `CELILO_HOOK_JAIL=${process.env.CELILO_HOOK_JAIL}, so the operator switched the ${availability.backend} jail off here`;
214
+ // run on a backend-less host reading as "the jail was proven". The only
215
+ // way here is `backend === 'none'` — an operator who set the policy off
216
+ // does NOT land in this companion, because the live suite above now pins
217
+ // the policy itself and runs anyway (celilo#1359).
218
+ const why = availability.reason ?? 'no backend';
216
219
  console.log(
217
- `\nhook jail: SKIPPED the live suite — ${why}\nThese properties are proven wherever a backend is available and enabled. The hermetic half runs everywhere (jail.test.ts).`,
220
+ `\nhook jail: SKIPPED the live suite — ${why}\nThese properties are proven wherever a jail backend is available. The hermetic half runs everywhere (jail.test.ts).`,
218
221
  );
219
- // Both reasons are legitimate and they are different facts, so assert the
220
- // disjunction rather than one of them. Asserting `backend === 'none'` alone
221
- // went red the day macOS got a backend AND the operator turned it off — a
222
- // combination that is not a defect in anything.
223
- expect(availability.backend === 'none' || jailPolicy() === 'off').toBe(true);
222
+ expect(availability.backend === 'none').toBe(true);
224
223
  });
225
224
  });
@@ -33,7 +33,7 @@ import {
33
33
  } from '../../../../scripts/workspace-fallback';
34
34
  import { CLI_BIN_DIR, childPath, executeHookScript, hookChildEnv } from './executor';
35
35
  import { HOOK_PROTOCOL_VERSION } from './hook-protocol';
36
- import { detectJailBackend, jailPolicy } from './jail';
36
+ import { detectJailBackend } from './jail';
37
37
  import { createCapturingLogger } from './logger';
38
38
  import { configStore, secretStore } from './test-fixtures/store-backed';
39
39
  import type { HookContext } from './types';
@@ -302,7 +302,14 @@ describe('hook process boundary — hello-trespass gate', () => {
302
302
  });
303
303
 
304
304
  const availability = detectJailBackend();
305
- const jailed = availability.backend !== 'none' && jailPolicy() !== 'off';
305
+ // Gated on the BACKEND alone, not on `jailPolicy()`. The ambient policy is an
306
+ // operator switch (`CELILO_HOOK_JAIL=off` is the default), and reading it here
307
+ // made stage 2 skip on every host with the default — measuring nothing. The
308
+ // jailed run pins the policy to `required` inside `runTrespass(true)` and
309
+ // restores it after, so the jail under test is this suite's own act wherever a
310
+ // backend exists. (celilo#1359: since the default became `off`, all three
311
+ // jailed suites skipped everywhere.)
312
+ const jailed = availability.backend !== 'none';
306
313
 
307
314
  describe.skipIf(!jailed)(
308
315
  `stage 2: the same hook, jailed (backend: ${availability.backend})`,