@celilo/cli 2.2.1 → 3.0.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 +2 -1
- 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/serve.ts +13 -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.test.ts +35 -0
- package/src/cli/commands/module-health.ts +12 -3
- 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 +28 -6
- package/src/cli/commands/module-publish.ts +14 -12
- package/src/cli/commands/module-update.test.ts +22 -7
- package/src/cli/commands/module-update.ts +73 -18
- package/src/cli/commands/module-upgrade-gate.test.ts +154 -0
- package/src/cli/commands/module-upgrade.test.ts +115 -1
- package/src/cli/commands/module-upgrade.ts +66 -4
- package/src/cli/commands/module-verify.test.ts +1 -1
- package/src/cli/commands/publish/execute.ts +4 -1
- package/src/cli/commands/publish/helpers.ts +4 -3
- package/src/cli/commands/publish/index.ts +23 -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.test.ts +64 -0
- package/src/cli/commands/publish/plan.ts +52 -19
- package/src/cli/commands/publish/types.ts +20 -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/subscribers-install-daemon.test.ts +44 -0
- package/src/cli/commands/subscribers-install-daemon.ts +107 -0
- package/src/cli/commands/subscribers-serve.test.ts +22 -0
- package/src/cli/commands/subscribers-serve.ts +22 -4
- package/src/cli/commands/system-audit.ts +34 -11
- 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 +16 -6
- package/src/cli/completion.ts +18 -3
- package/src/cli/flag-surface-gate.test.ts +279 -0
- package/src/cli/fuel-gauge.ts +12 -4
- package/src/cli/index.ts +80 -2
- package/src/cli/json-output.test.ts +81 -0
- 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/cli/types.ts +9 -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/broker.test.ts +9 -3
- package/src/hooks/capability-loader-firewall.test.ts +9 -1
- package/src/hooks/capability-loader.ts +8 -0
- package/src/hooks/executor.test.ts +91 -14
- package/src/hooks/executor.ts +31 -1
- package/src/hooks/hook-jail-toolchain-reach.test.ts +8 -3
- package/src/hooks/hook-jail-unreachability.test.ts +27 -20
- package/src/hooks/hook-trespass.test.ts +22 -6
- package/src/hooks/jail.test.ts +105 -17
- package/src/hooks/jail.ts +47 -10
- package/src/hooks/run-named-hook.ts +19 -16
- package/src/hooks/test-fixtures/artifact-writing-hook.ts +0 -1
- package/src/hooks/test-fixtures/capability-calling-hook.ts +20 -13
- package/src/hooks/test-fixtures/jail-probe-hook.ts +9 -1
- package/src/hooks/test-fixtures/jail-toolchain-hook.ts +7 -2
- package/src/hooks/test-fixtures/runaway-hook.ts +0 -1
- package/src/hooks/test-fixtures/sigterm-ignoring-hook.ts +0 -1
- package/src/hooks/test-fixtures/silent-hook.ts +0 -1
- package/src/hooks/test-fixtures/store-writing-hook.ts +20 -11
- package/src/hooks/test-fixtures/success-hook.ts +4 -4
- package/src/manifest/contracts/v1.ts +32 -14
- package/src/manifest/json-schema-roundtrip.test.ts +1 -1
- package/src/manifest/schema.ts +66 -16
- package/src/manifest/validate.test.ts +47 -0
- package/src/policy/capability-shape-baseline.ts +63 -21
- package/src/policy/capability-shape-drift.test.ts +53 -1
- package/src/policy/capability-shape.test.ts +105 -0
- package/src/policy/capability-shape.ts +283 -2
- 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 +67 -2
- package/src/registry/client.ts +7 -7
- package/src/secrets/storage.test.ts +70 -4
- package/src/secrets/storage.ts +71 -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 +155 -2
- package/src/services/audit/health.ts +76 -2
- package/src/services/audit/index.test.ts +23 -1
- package/src/services/audit/index.ts +8 -2
- package/src/services/audit/interface-classification.test.ts +16 -5
- package/src/services/audit/interface-classification.ts +25 -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/module-versions.ts +5 -1
- package/src/services/audit/public-dns.test.ts +20 -0
- package/src/services/audit/public-dns.ts +7 -2
- package/src/services/audit/recurrence-gate.test.ts +225 -0
- package/src/services/audit/trusted-sources.test.ts +17 -0
- package/src/services/audit/trusted-sources.ts +21 -0
- 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-executor.test.ts +269 -0
- package/src/services/build-bus/hook-dispatch-mgmt.test.ts +105 -105
- package/src/services/build-bus/hook-dispatch-path.test.ts +95 -0
- package/src/services/build-bus/hook-dispatch.test.ts +86 -116
- package/src/services/build-bus/hook-dispatch.ts +99 -121
- package/src/services/build-bus/hook-dispatcher.ts +143 -17
- package/src/services/build-bus/receiver-daemon.test.ts +189 -0
- package/src/services/build-bus/receiver-daemon.ts +355 -0
- package/src/services/build-bus/self-update.ts +156 -0
- 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/capability-compat.test.ts +90 -0
- package/src/services/capability-compat.ts +128 -0
- 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/deploy-terraform.ts +38 -1
- package/src/services/deployed-systems.ts +1 -1
- package/src/services/events-daemon.test.ts +57 -0
- package/src/services/events-daemon.ts +76 -0
- package/src/services/firewall-reach.ts +21 -8
- package/src/services/fleet-checks.test.ts +159 -4
- package/src/services/fleet-checks.ts +206 -3
- package/src/services/health-runner.test.ts +87 -2
- package/src/services/health-runner.ts +83 -16
- 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-prune.test.ts +89 -0
- package/src/services/module-deploy.ts +84 -129
- package/src/services/module-subscriptions.test.ts +2 -2
- package/src/services/module-types-drift.test.ts +1 -1
- package/src/services/module-validator/capability-versions.test.ts +13 -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/terraform-safety.test.ts +83 -0
- package/src/services/terraform-safety.ts +53 -0
- package/src/services/update/orchestrator.test.ts +3 -1
- package/src/test-utils/bus-responder.ts +1 -1
- package/tsconfig.json +2 -13
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;
|
package/src/hooks/broker.test.ts
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
import { describe, expect, test } from 'bun:test';
|
|
14
14
|
import { execSync } from 'node:child_process';
|
|
15
|
-
import { mkdtempSync, rmSync } from 'node:fs';
|
|
15
|
+
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
|
16
16
|
import { tmpdir } from 'node:os';
|
|
17
17
|
import { join } from 'node:path';
|
|
18
18
|
import { MissingProviderInputError } from '@celilo/capabilities';
|
|
@@ -60,10 +60,13 @@ async function runCapabilityHook(): Promise<Record<string, unknown>> {
|
|
|
60
60
|
stateDir: dir,
|
|
61
61
|
capabilities: demoCapabilities(),
|
|
62
62
|
};
|
|
63
|
-
|
|
63
|
+
await executeHookScript(join(FIXTURES, 'capability-calling-hook.ts'), context, {
|
|
64
64
|
timeoutMs: 30_000,
|
|
65
65
|
idleTimeoutMs: 30_000,
|
|
66
66
|
});
|
|
67
|
+
// The fixture hands its report through the state directory: hook return
|
|
68
|
+
// values are no longer carried anywhere (hook-owned-state D5).
|
|
69
|
+
return JSON.parse(readFileSync(join(dir, 'report.json'), 'utf-8')) as Record<string, unknown>;
|
|
67
70
|
} finally {
|
|
68
71
|
rmSync(dir, { recursive: true, force: true });
|
|
69
72
|
}
|
|
@@ -137,11 +140,14 @@ async function runStoreHook(
|
|
|
137
140
|
capabilities: {},
|
|
138
141
|
...contextData,
|
|
139
142
|
} as unknown as HookContext;
|
|
140
|
-
|
|
143
|
+
await executeHookScript(join(FIXTURES, 'store-writing-hook.ts'), context, {
|
|
141
144
|
timeoutMs: 30_000,
|
|
142
145
|
idleTimeoutMs: 30_000,
|
|
143
146
|
hookStores: () => Promise.resolve(memoryStores()),
|
|
144
147
|
});
|
|
148
|
+
// The fixture hands its report through the state directory: hook return
|
|
149
|
+
// values are no longer carried anywhere (hook-owned-state D5).
|
|
150
|
+
return JSON.parse(readFileSync(join(dir, 'report.json'), 'utf-8')) as Record<string, unknown>;
|
|
145
151
|
} finally {
|
|
146
152
|
rmSync(dir, { recursive: true, force: true });
|
|
147
153
|
}
|
|
@@ -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 {
|
|
@@ -131,7 +131,8 @@ describe('Hook Executor', () => {
|
|
|
131
131
|
vps_ip: '10.0.0.1',
|
|
132
132
|
});
|
|
133
133
|
|
|
134
|
-
|
|
134
|
+
// Void hook: no outputs. The envelope carries an empty record.
|
|
135
|
+
expect(result).toEqual({});
|
|
135
136
|
expect(messages).toContainEqual({ level: 'info', message: 'Starting test hook' });
|
|
136
137
|
expect(messages).toContainEqual({ level: 'success', message: 'Test hook completed' });
|
|
137
138
|
});
|
|
@@ -347,18 +348,53 @@ describe('Hook Executor', () => {
|
|
|
347
348
|
getDb().insert(systemConfig).values({ key: 'hooks.jail_policy', value }).run();
|
|
348
349
|
};
|
|
349
350
|
|
|
350
|
-
|
|
351
|
+
// The per-module row is keyed by module id, but the executor is handed
|
|
352
|
+
// the module's tree — the join the production read makes (task 1.4). The
|
|
353
|
+
// path is fake: nothing in this describe touches the filesystem beyond
|
|
354
|
+
// the scratch DB, and `existsSync(scriptPath)` fails before any policy
|
|
355
|
+
// read when it is not fake.
|
|
356
|
+
const moduleTree = (): string => join(dir, 'modules', 'fixture-module');
|
|
357
|
+
const plantModulePolicyRow = (moduleId: string, policy: string): void => {
|
|
358
|
+
getDb()
|
|
359
|
+
.insert(modules)
|
|
360
|
+
.values({
|
|
361
|
+
id: moduleId,
|
|
362
|
+
name: moduleId,
|
|
363
|
+
version: '1.0.0',
|
|
364
|
+
manifestData: {},
|
|
365
|
+
sourcePath: moduleTree(),
|
|
366
|
+
})
|
|
367
|
+
.run();
|
|
368
|
+
// Raw SQL, not the drizzle table, on purpose: the drizzle type only
|
|
369
|
+
// accepts 'auto' | 'off' | 'required', and the bad-value test models a
|
|
370
|
+
// row that arrived past every typed writer — a restore or a hand edit,
|
|
371
|
+
// the untrusted path the resolver throws on (Rule 3.7).
|
|
372
|
+
getDb()
|
|
373
|
+
.$client.prepare('INSERT INTO module_jail_policies (module_id, policy) VALUES (?, ?)')
|
|
374
|
+
.run(moduleId, policy);
|
|
375
|
+
};
|
|
376
|
+
|
|
377
|
+
const runVoidHook = (moduleTree?: string): Promise<Record<string, unknown>> => {
|
|
351
378
|
const { logger } = createCapturingLogger();
|
|
352
|
-
return executeHookScript(
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
379
|
+
return executeHookScript(
|
|
380
|
+
join(FIXTURES_DIR, 'void-hook.ts'),
|
|
381
|
+
{
|
|
382
|
+
config: configStore(),
|
|
383
|
+
secrets: secretStore(),
|
|
384
|
+
systems: [],
|
|
385
|
+
logger,
|
|
386
|
+
debug: false,
|
|
387
|
+
screenshotDir: '/tmp',
|
|
388
|
+
stateDir: '/tmp',
|
|
389
|
+
capabilities: {},
|
|
390
|
+
},
|
|
391
|
+
{
|
|
392
|
+
// Absent means the system-config-only path: exactly what the tests
|
|
393
|
+
// above exercise. Present means the executor reads the module's row
|
|
394
|
+
// and resolves through it.
|
|
395
|
+
jail: moduleTree === undefined ? undefined : { modulePath: moduleTree, pathInputs: [] },
|
|
396
|
+
},
|
|
397
|
+
);
|
|
362
398
|
};
|
|
363
399
|
|
|
364
400
|
beforeEach(async () => {
|
|
@@ -413,6 +449,45 @@ describe('Hook Executor', () => {
|
|
|
413
449
|
// resolver falls through to the default, which jails nobody.
|
|
414
450
|
await expect(runVoidHook()).resolves.toBeDefined();
|
|
415
451
|
});
|
|
452
|
+
|
|
453
|
+
test('the module row beats system config (per-module-jail-policy task 1.4)', async () => {
|
|
454
|
+
// System says jail hard; the module is exempted. The exempted module's
|
|
455
|
+
// hook runs unjailed while every other module of this host would fail —
|
|
456
|
+
// the spec's own "A module runs unjailed while the fleet is jailed".
|
|
457
|
+
plantPolicyRow('required');
|
|
458
|
+
plantModulePolicyRow('exempted-module', 'off');
|
|
459
|
+
await expect(runVoidHook(moduleTree())).resolves.toBeDefined();
|
|
460
|
+
// And the same host still jails a module with no row: `required` with
|
|
461
|
+
// no jail backend is a hard failure, so the system row stays in force.
|
|
462
|
+
// Which surface the failure takes is host-determined, and both prove
|
|
463
|
+
// the hook did not run: on a host with a jail backend it dies at mount
|
|
464
|
+
// planning (the fake module tree does not exist, "cannot be built");
|
|
465
|
+
// on a host with no backend it dies at backend detection ("no hook
|
|
466
|
+
// jail is available"). The point under test is policy precedence, not
|
|
467
|
+
// the surface. (celilo#1342: the CI runner has no bubblewrap, so the
|
|
468
|
+
// mount-planning surface is unreachable there.)
|
|
469
|
+
await expect(runVoidHook(join(dir, 'modules', 'other-module'))).rejects.toThrow(
|
|
470
|
+
/cannot be built|no hook jail is available/,
|
|
471
|
+
);
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
test('a bad per-module row fails the hook instead of running it unjailed', async () => {
|
|
475
|
+
plantModulePolicyRow('broken-module', 'alwayssafe');
|
|
476
|
+
await expect(runVoidHook(moduleTree())).rejects.toThrow(/module's jail policy='alwayssafe'/);
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
test('env beats a conflicting module row', async () => {
|
|
480
|
+
plantModulePolicyRow('exempted-module', 'off');
|
|
481
|
+
process.env.CELILO_HOOK_JAIL = 'required';
|
|
482
|
+
// The point is that the env overrode the module's `off`, so the hook
|
|
483
|
+
// is jailed and fails, not exempted and successful. Which failure it
|
|
484
|
+
// hits is host-determined: mount planning (the fake module tree does
|
|
485
|
+
// not exist) on a host with a jail backend, backend detection on a
|
|
486
|
+
// host without one. Either proves the hook did not run.
|
|
487
|
+
await expect(runVoidHook(moduleTree())).rejects.toThrow(
|
|
488
|
+
/cannot be built|no hook jail is available/,
|
|
489
|
+
);
|
|
490
|
+
});
|
|
416
491
|
});
|
|
417
492
|
|
|
418
493
|
describe('resolveHookTimeouts', () => {
|
|
@@ -548,7 +623,9 @@ describe('Hook Executor', () => {
|
|
|
548
623
|
);
|
|
549
624
|
|
|
550
625
|
expect(result.success).toBe(true);
|
|
551
|
-
|
|
626
|
+
// Hook return values are no longer consumed (hook-owned-state D5): a
|
|
627
|
+
// void hook's outputs are empty, and nothing persists them.
|
|
628
|
+
expect(result.outputs).toEqual({});
|
|
552
629
|
expect(result.duration).toBeGreaterThanOrEqual(0);
|
|
553
630
|
expect(messages.some((m) => m.message.includes('completed successfully'))).toBe(true);
|
|
554
631
|
});
|
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
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
*/
|
|
32
32
|
|
|
33
33
|
import { describe, expect, test } from 'bun:test';
|
|
34
|
-
import { existsSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs';
|
|
34
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
|
35
35
|
import { tmpdir } from 'node:os';
|
|
36
36
|
import { dirname, join, resolve } from 'node:path';
|
|
37
37
|
import { executeHookScript } from './executor';
|
|
@@ -132,7 +132,7 @@ async function measureReach(): Promise<{ reach: Reach; cleanup: () => void }> {
|
|
|
132
132
|
const savedPolicy = process.env.CELILO_HOOK_JAIL;
|
|
133
133
|
process.env.CELILO_HOOK_JAIL = 'required';
|
|
134
134
|
try {
|
|
135
|
-
|
|
135
|
+
await executeHookScript(PROBE_HOOK, context, {
|
|
136
136
|
timeoutMs: 60_000,
|
|
137
137
|
idleTimeoutMs: 60_000,
|
|
138
138
|
jail: {
|
|
@@ -145,7 +145,12 @@ async function measureReach(): Promise<{ reach: Reach; cleanup: () => void }> {
|
|
|
145
145
|
? [{ name: 'browser_executable', value: probeBrowserDir(), access: 'read' as const }]
|
|
146
146
|
: [],
|
|
147
147
|
},
|
|
148
|
-
})
|
|
148
|
+
});
|
|
149
|
+
// The fixture hands its report through the state directory: hook return
|
|
150
|
+
// values are no longer carried anywhere (hook-owned-state D5).
|
|
151
|
+
const reach = JSON.parse(
|
|
152
|
+
readFileSync(join(stateDir, 'report.json'), 'utf-8'),
|
|
153
|
+
) as unknown as Reach;
|
|
149
154
|
|
|
150
155
|
return { reach, cleanup: restorePolicy };
|
|
151
156
|
} catch (error) {
|
|
@@ -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: {
|
|
@@ -66,6 +69,9 @@ interface Rig {
|
|
|
66
69
|
cleanup: () => void;
|
|
67
70
|
}
|
|
68
71
|
|
|
72
|
+
/** Where the probe fixture hands its report to the parent (hook-owned-state D5). */
|
|
73
|
+
const REPORT_PATH = 'report.json';
|
|
74
|
+
|
|
69
75
|
/**
|
|
70
76
|
* Lay out a module store the way celilo does, run the probe hook against it,
|
|
71
77
|
* and hand back both the hook's report and the paths so the parent can check
|
|
@@ -131,14 +137,19 @@ async function runProbe(): Promise<Rig> {
|
|
|
131
137
|
const savedPolicy = process.env.CELILO_HOOK_JAIL;
|
|
132
138
|
process.env.CELILO_HOOK_JAIL = 'required';
|
|
133
139
|
try {
|
|
134
|
-
|
|
140
|
+
await executeHookScript(PROBE_HOOK, context, {
|
|
135
141
|
timeoutMs: 60_000,
|
|
136
142
|
idleTimeoutMs: 60_000,
|
|
137
143
|
jail: {
|
|
138
144
|
modulePath,
|
|
139
145
|
pathInputs: [{ name: 'staged_input', value: stagedInput, access: 'write' }],
|
|
140
146
|
},
|
|
141
|
-
})
|
|
147
|
+
});
|
|
148
|
+
// The fixture hands its report through the state directory: hook return
|
|
149
|
+
// values are no longer carried anywhere (hook-owned-state D5).
|
|
150
|
+
const outputs = JSON.parse(
|
|
151
|
+
readFileSync(join(stateDir, REPORT_PATH), 'utf-8'),
|
|
152
|
+
) as unknown as Rig['outputs'];
|
|
142
153
|
return {
|
|
143
154
|
outputs,
|
|
144
155
|
stagedInput,
|
|
@@ -208,18 +219,14 @@ describe.skipIf(!jailed)(`the jail is real (backend: ${availability.backend})`,
|
|
|
208
219
|
describe.skipIf(jailed)('this run is not jailed', () => {
|
|
209
220
|
test('says so, rather than reporting a pass it did not earn', () => {
|
|
210
221
|
// Not an assertion about the product. It is the line that stops a green
|
|
211
|
-
// run on
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
222
|
+
// run on a backend-less host reading as "the jail was proven". The only
|
|
223
|
+
// way here is `backend === 'none'` — an operator who set the policy off
|
|
224
|
+
// does NOT land in this companion, because the live suite above now pins
|
|
225
|
+
// the policy itself and runs anyway (celilo#1359).
|
|
226
|
+
const why = availability.reason ?? 'no backend';
|
|
216
227
|
console.log(
|
|
217
|
-
`\nhook jail: SKIPPED the live suite — ${why}\nThese properties are proven wherever a backend is available
|
|
228
|
+
`\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
229
|
);
|
|
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);
|
|
230
|
+
expect(availability.backend === 'none').toBe(true);
|
|
224
231
|
});
|
|
225
232
|
});
|
|
@@ -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';
|
|
@@ -155,14 +155,16 @@ afterAll(() => {
|
|
|
155
155
|
|
|
156
156
|
async function runTrespass(jail = false): Promise<{ report: TrespassReport; lines: string[] }> {
|
|
157
157
|
const { logger, messages } = createCapturingLogger();
|
|
158
|
+
const screenshotDir = mkdtempSync(join(tmpdir(), 'celilo-trespass-artifacts-'));
|
|
159
|
+
const stateDir = mkdtempSync(join(tmpdir(), 'celilo-trespass-artifacts-'));
|
|
158
160
|
const context: HookContext = {
|
|
159
161
|
config: configStore({ sibling_module_id: 'hello-foo', other_system_ip: '' }),
|
|
160
162
|
secrets: secretStore(),
|
|
161
163
|
systems: [],
|
|
162
164
|
logger,
|
|
163
165
|
debug: false,
|
|
164
|
-
screenshotDir
|
|
165
|
-
stateDir
|
|
166
|
+
screenshotDir,
|
|
167
|
+
stateDir,
|
|
166
168
|
capabilities: {},
|
|
167
169
|
};
|
|
168
170
|
|
|
@@ -173,7 +175,7 @@ async function runTrespass(jail = false): Promise<{ report: TrespassReport; line
|
|
|
173
175
|
const savedPolicy = process.env.CELILO_HOOK_JAIL;
|
|
174
176
|
if (jail) process.env.CELILO_HOOK_JAIL = 'required';
|
|
175
177
|
try {
|
|
176
|
-
|
|
178
|
+
await executeHookScript(TRESPASS_SCRIPT, context, {
|
|
177
179
|
timeoutMs: 60_000,
|
|
178
180
|
idleTimeoutMs: 60_000,
|
|
179
181
|
// The module's own tree, two levels above the hook script. Absent means
|
|
@@ -181,12 +183,19 @@ async function runTrespass(jail = false): Promise<{ report: TrespassReport; line
|
|
|
181
183
|
...(jail ? { jail: { modulePath: dirname(dirname(TRESPASS_SCRIPT)), pathInputs: [] } } : {}),
|
|
182
184
|
});
|
|
183
185
|
return {
|
|
184
|
-
report
|
|
186
|
+
// The fixture hands its report through the state directory the jail
|
|
187
|
+
// binds read-write: the same channel works jailed and unjailed (D5
|
|
188
|
+
// removed the return channel).
|
|
189
|
+
report: JSON.parse(
|
|
190
|
+
readFileSync(join(stateDir, 'report.json'), 'utf-8'),
|
|
191
|
+
) as unknown as TrespassReport,
|
|
185
192
|
lines: messages.map((m) => m.message),
|
|
186
193
|
};
|
|
187
194
|
} finally {
|
|
188
195
|
if (savedPolicy === undefined) delete process.env.CELILO_HOOK_JAIL;
|
|
189
196
|
else process.env.CELILO_HOOK_JAIL = savedPolicy;
|
|
197
|
+
rmSync(screenshotDir, { recursive: true, force: true });
|
|
198
|
+
rmSync(stateDir, { recursive: true, force: true });
|
|
190
199
|
}
|
|
191
200
|
}
|
|
192
201
|
|
|
@@ -302,7 +311,14 @@ describe('hook process boundary — hello-trespass gate', () => {
|
|
|
302
311
|
});
|
|
303
312
|
|
|
304
313
|
const availability = detectJailBackend();
|
|
305
|
-
|
|
314
|
+
// Gated on the BACKEND alone, not on `jailPolicy()`. The ambient policy is an
|
|
315
|
+
// operator switch (`CELILO_HOOK_JAIL=off` is the default), and reading it here
|
|
316
|
+
// made stage 2 skip on every host with the default — measuring nothing. The
|
|
317
|
+
// jailed run pins the policy to `required` inside `runTrespass(true)` and
|
|
318
|
+
// restores it after, so the jail under test is this suite's own act wherever a
|
|
319
|
+
// backend exists. (celilo#1359: since the default became `off`, all three
|
|
320
|
+
// jailed suites skipped everywhere.)
|
|
321
|
+
const jailed = availability.backend !== 'none';
|
|
306
322
|
|
|
307
323
|
describe.skipIf(!jailed)(
|
|
308
324
|
`stage 2: the same hook, jailed (backend: ${availability.backend})`,
|