@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/db/client.test.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
-
|
|
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(
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
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', () => {
|
package/src/hooks/executor.ts
CHANGED
|
@@ -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(
|
|
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
|
|
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
|
-
//
|
|
50
|
-
// operator switch
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
|
|
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
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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})`,
|