@celilo/cli 1.12.0 → 1.14.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 (67) hide show
  1. package/CELILO_CORE_MODULES.md +2 -1
  2. package/CELILO_SUBSYSTEMS.md +21 -2
  3. package/package.json +3 -3
  4. package/src/capabilities/public-web-helpers.test.ts +12 -6
  5. package/src/capabilities/public-web-publish.test.ts +24 -13
  6. package/src/capabilities/validation.test.ts +31 -0
  7. package/src/cli/commands/alerts-list.ts +16 -1
  8. package/src/cli/commands/backup-list.test.ts +82 -1
  9. package/src/cli/commands/backup-list.ts +113 -4
  10. package/src/cli/commands/console-get-chain.test.ts +96 -0
  11. package/src/cli/commands/console.ts +130 -0
  12. package/src/cli/commands/module-list.ts +3 -41
  13. package/src/cli/commands/notify-config.test.ts +79 -0
  14. package/src/cli/commands/notify-config.ts +13 -2
  15. package/src/cli/commands/system-ensure-fleet-key.ts +52 -0
  16. package/src/cli/completion.ts +6 -0
  17. package/src/cli/index.ts +31 -1
  18. package/src/console/closure.test.ts +322 -0
  19. package/src/console/closure.ts +294 -0
  20. package/src/console/control-plane-boundary.test.ts +75 -0
  21. package/src/console/projection.test.ts +293 -0
  22. package/src/console/projection.ts +364 -0
  23. package/src/db/schema.ts +19 -14
  24. package/src/hooks/broker.test.ts +4 -6
  25. package/src/hooks/capability-loader-control-plane-api.test.ts +124 -0
  26. package/src/hooks/capability-loader.ts +67 -10
  27. package/src/hooks/executor.test.ts +85 -4
  28. package/src/hooks/executor.ts +164 -9
  29. package/src/hooks/hook-jail-unreachability.test.ts +173 -0
  30. package/src/hooks/hook-state-dir.test.ts +14 -2
  31. package/src/hooks/hook-timeout.test.ts +2 -4
  32. package/src/hooks/hook-trespass.test.ts +50 -5
  33. package/src/hooks/jail.test.ts +370 -0
  34. package/src/hooks/jail.ts +491 -0
  35. package/src/hooks/mount-set.ts +24 -0
  36. package/src/hooks/test-fixtures/jail-probe-hook.ts +59 -0
  37. package/src/manifest/contracts/v1.ts +22 -1
  38. package/src/manifest/schema.ts +35 -0
  39. package/src/manifest/validate.test.ts +142 -0
  40. package/src/manifest/validate.ts +126 -4
  41. package/src/module/import.test.ts +116 -0
  42. package/src/module/import.ts +73 -1
  43. package/src/module/packaging/audit.ts +103 -1
  44. package/src/module/packaging/classify-module-path.test.ts +36 -0
  45. package/src/module/packaging/package-rules.ts +18 -0
  46. package/src/module/web-root.ts +35 -0
  47. package/src/policy/capability-shape-baseline.ts +8 -0
  48. package/src/policy/module-business-baseline.ts +26 -2
  49. package/src/policy/module-script-scan.test.ts +22 -0
  50. package/src/policy/module-script-scan.ts +32 -0
  51. package/src/services/alerting/observed-health.ts +71 -0
  52. package/src/services/api-principal-enrolment.test.ts +252 -0
  53. package/src/services/api-principal-enrolment.ts +158 -0
  54. package/src/services/audit/backups.ts +10 -1
  55. package/src/services/backup-create.ts +33 -7
  56. package/src/services/backup-metadata.ts +19 -11
  57. package/src/services/celilo-mgmt-hooks.test.ts +38 -79
  58. package/src/services/consumer-cleanup.ts +31 -5
  59. package/src/services/fleet-key.test.ts +47 -0
  60. package/src/services/fleet-key.ts +75 -0
  61. package/src/services/instance-ops.test.ts +302 -0
  62. package/src/services/instance-ops.ts +292 -0
  63. package/src/services/module-instances.test.ts +428 -42
  64. package/src/services/module-instances.ts +219 -26
  65. package/src/services/restore-from-file.ts +6 -5
  66. package/src/services/system-state-stage.test.ts +165 -0
  67. package/src/services/system-state-stage.ts +196 -0
@@ -15,6 +15,7 @@ import { join } from 'node:path';
15
15
  import {
16
16
  createPublicWeb,
17
17
  isCompiledCapabilityFactory,
18
+ orderFirewallChain,
18
19
  wrapWithLogging,
19
20
  } from '@celilo/capabilities';
20
21
  import type {
@@ -38,8 +39,10 @@ import {
38
39
  systemConfig,
39
40
  webRoutes,
40
41
  } from '../db/schema';
42
+ import { resolveModuleWebRoot } from '../module/web-root';
41
43
  import { decryptSecret } from '../secrets/encryption';
42
44
  import { getOrCreateMasterKey } from '../secrets/master-key';
45
+ import { buildControlPlaneApi } from '../services/api-principal-enrolment';
43
46
  import { recordCapabilityBinding, withBindingRecord } from '../services/capability-bindings';
44
47
  import { emitWebRoutesChangedAndWait } from '../services/celilo-events';
45
48
  import { getModuleSystems } from '../services/deployed-systems';
@@ -219,6 +222,26 @@ export async function resolveCaddyZoneIp(db: DbClient): Promise<string | undefin
219
222
  return undefined;
220
223
  }
221
224
 
225
+ /**
226
+ * Does this module's manifest declare `capabilityName`, under `requires` or
227
+ * `optional`?
228
+ *
229
+ * Reads the manifest stored at import rather than the one on disk, so the
230
+ * answer is the one the operator's `module import` actually validated.
231
+ */
232
+ function consumerDeclares(db: DbClient, moduleId: string, capabilityName: string): boolean {
233
+ const row = db.select().from(modules).where(eq(modules.id, moduleId)).get();
234
+ const manifest = row?.manifestData;
235
+ if (!manifest) return false;
236
+
237
+ type Declarations = { capabilities?: { name?: string }[] } | undefined;
238
+ const declared = [
239
+ ...((manifest.requires as Declarations)?.capabilities ?? []),
240
+ ...((manifest.optional as Declarations)?.capabilities ?? []),
241
+ ];
242
+ return declared.some((cap) => cap?.name === capabilityName);
243
+ }
244
+
222
245
  export async function loadCapabilityFunctions(
223
246
  consumingModuleId: string,
224
247
  db: DbClient,
@@ -380,6 +403,11 @@ export async function loadCapabilityFunctions(
380
403
  // which made a module-provided capability structurally unable to
381
404
  // offer `unregisterRoutes()`-style methods.
382
405
  consumerModuleId: consumingModuleId,
406
+ // WHERE THE CALLER'S BYTES ARE, for the same reason and by the same
407
+ // seam. `private_web` and `external_web` publish static sites too, and
408
+ // without this they could not find the caller's web root now that
409
+ // `sourceDir` has left the request (D10 amendment).
410
+ consumerWebRoot: resolveModuleWebRoot(consumingModuleId, db),
383
411
  });
384
412
  // Stamp here too, not only on the legacy path: a consumer that cannot
385
413
  // get what it needs must be able to name WHICH provider could not give
@@ -563,6 +591,9 @@ export async function loadCapabilityFunctions(
563
591
  providerByCapability.set(provider.capabilityName, provider.moduleId);
564
592
  result.public_web = createPublicWeb({
565
593
  moduleId: consumingModuleId,
594
+ // Resolved here, not in the capability: on a converge there is no
595
+ // consumer process to ask (D10 amendment).
596
+ webRoot: resolveModuleWebRoot(consumingModuleId, db),
566
597
  logger,
567
598
  config: providerConfig,
568
599
  secrets: providerSecrets,
@@ -641,6 +672,30 @@ export async function loadCapabilityFunctions(
641
672
  debugLog('public_web: not registered in DB, skipping');
642
673
  }
643
674
 
675
+ // Framework-granted, so unlike everything above there is no provider row to
676
+ // look up and no script to import — celilo IS the management server whose
677
+ // principals these are (web-ui-console D7b).
678
+ //
679
+ // Gated on the DECLARATION, which the rest of this function deliberately is
680
+ // not: the loop injects every capability it can build "not just required
681
+ // ones", so a module that never asked still gets `idp` and `firewall`. That is
682
+ // fine for a capability whose worst outcome is an unused OIDC client. This one
683
+ // mints an SSH principal into celilo's own control plane, so the `requires`
684
+ // line is the authorization and a module that did not write one does not get
685
+ // the object at all.
686
+ if (consumerDeclares(db, consumingModuleId, 'control_plane_api')) {
687
+ // Wrapped like every other capability, so an enrolment that fails mid-deploy
688
+ // leaves a `✗ control_plane_api.enrol_principal` in the hook log rather than
689
+ // only a thrown error further up. The `defineCapabilityFunction` path wraps
690
+ // itself; a framework-built table has to be wrapped here.
691
+ result.control_plane_api = wrapWithLogging(
692
+ buildControlPlaneApi(consumingModuleId),
693
+ logger,
694
+ 'control_plane_api',
695
+ );
696
+ debugLog(`control_plane_api: framework-granted, injected for ${consumingModuleId}`);
697
+ }
698
+
644
699
  // celilo#1072: the CALL is the binding, not the resolution. Everything above
645
700
  // is injected whether or not the consumer declared it — the loop's own
646
701
  // comment says "not just required ones" — so recording what was resolved
@@ -1104,20 +1159,19 @@ async function buildFirewallChain(
1104
1159
  // attributable. Only the layers that render their own ruleset receive it.
1105
1160
  const trustedSourceStore = buildTrustedSourceStore(db, consumingModuleId);
1106
1161
 
1107
- // Find the provider with has_external (the leaf — has direct internet access)
1108
- const hasExternal = allProviders.find((p) => {
1109
- const data = (typeof p.data === 'string' ? JSON.parse(p.data) : p.data) as Record<
1110
- string,
1111
- unknown
1112
- >;
1113
- return data.has_external === true;
1114
- });
1162
+ // The delegation order, most downstream first. Shared with the console's
1163
+ // closure so the wiring and the picture cannot disagree about which firewall
1164
+ // stands on which — see `orderFirewallChain`.
1165
+ const chainOrder = orderFirewallChain(allProviders);
1115
1166
 
1116
- if (!hasExternal) {
1167
+ if (chainOrder.length === 0) {
1117
1168
  debugLog('firewall chain: no provider with external interface found');
1118
1169
  return { chain: null, self: null };
1119
1170
  }
1120
1171
 
1172
+ // The leaf is the upstream end: the provider with the direct internet leg.
1173
+ const hasExternal = chainOrder[chainOrder.length - 1] as (typeof chainOrder)[number];
1174
+
1121
1175
  // Build the leaf (external) firewall first
1122
1176
  const leafModule = db.select().from(modules).where(eq(modules.id, hasExternal.moduleId)).get();
1123
1177
  if (!leafModule) return { chain: null, self: null };
@@ -1173,7 +1227,10 @@ async function buildFirewallChain(
1173
1227
  // Build downstream providers, wiring each to the upstream. iptables
1174
1228
  // remains a legacy factory because its second arg (upstreamFirewall)
1175
1229
  // doesn't fit defineCapabilityFunction's single-context shape.
1176
- const downstream = allProviders.filter((p) => p.moduleId !== hasExternal.moduleId);
1230
+ // Back into wiring order: each layer is built taking the previous one as its
1231
+ // upstream, so the build runs from the leaf outwards while the chain reads
1232
+ // from the consumer inwards.
1233
+ const downstream = chainOrder.slice(0, -1).reverse();
1177
1234
 
1178
1235
  // Each layer as its OWN provider sees it, so `on_consumer_removed` converges
1179
1236
  // the firewall that declares the hook rather than whichever layer a consumer
@@ -1,9 +1,11 @@
1
1
  import { describe, expect, test } from 'bun:test';
2
- import { existsSync, readdirSync, rmSync } from 'node:fs';
2
+ import { existsSync, mkdtempSync, readdirSync, rmSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
3
4
  import { join } from 'node:path';
4
5
  import type { ContractHookSignature } from '../manifest/contracts';
5
6
  import {
6
7
  checkRequiredCapabilities,
8
+ declaredPathInputs,
7
9
  executeHookScript,
8
10
  invokeHook,
9
11
  resolveHookScript,
@@ -11,6 +13,7 @@ import {
11
13
  validateHookInputs,
12
14
  validateHookOutputs,
13
15
  } from './executor';
16
+ import { readJailMode } from './jail';
14
17
  import { createCapturingLogger } from './logger';
15
18
  import type { HookDefinition } from './types';
16
19
 
@@ -241,12 +244,90 @@ describe('Hook Executor', () => {
241
244
 
242
245
  // 8s of silence against a 1ms idle bound: killed at the first poll.
243
246
  // Under the old hardcoded 30s idle this resolved instead.
244
- await expect(executeHookScript(scriptPath, context, 60_000, 1)).rejects.toThrow(
245
- 'idle timeout exceeded',
246
- );
247
+ await expect(
248
+ executeHookScript(scriptPath, context, { timeoutMs: 60_000, idleTimeoutMs: 1 }),
249
+ ).rejects.toThrow('idle timeout exceeded');
247
250
  }, 20_000);
248
251
  });
249
252
 
253
+ describe('declaredPathInputs', () => {
254
+ // The jail's bind mounts come from this, so a wrong answer here is a hook
255
+ // that cannot read a file it was handed — or one that can read a file
256
+ // nobody declared.
257
+ const SIG: ContractHookSignature = {
258
+ inputs: {
259
+ backup_dir: { required: true, path: { access: 'write' } },
260
+ artifact_path: { required: true, path: { access: 'read' } },
261
+ artifact_count: { required: false },
262
+ },
263
+ outputs: {},
264
+ };
265
+
266
+ test('walks the declaration and carries the declared access', () => {
267
+ expect(
268
+ declaredPathInputs(SIG, {
269
+ backup_dir: '/tmp/stage/data',
270
+ artifact_path: '/tmp/stage/db.sqlite',
271
+ artifact_count: 3,
272
+ }),
273
+ ).toEqual([
274
+ { name: 'backup_dir', value: '/tmp/stage/data', access: 'write' },
275
+ { name: 'artifact_path', value: '/tmp/stage/db.sqlite', access: 'read' },
276
+ ]);
277
+ });
278
+
279
+ test('a path-shaped input the contract does not declare contributes nothing', () => {
280
+ // `db_path` was passed by backup-create.ts for months and declared
281
+ // nowhere (celilo#1118). The derivation walks declarations, so the jail
282
+ // withholds it — which is the correct outcome and the reason the
283
+ // contract has to declare what it passes.
284
+ expect(declaredPathInputs(SIG, { db_path: '/var/celilo/celilo.db' })).toEqual([]);
285
+ });
286
+
287
+ test('a name that merely LOOKS like a path is not one', () => {
288
+ // Never inferred from the name. A heuristic silently changes what a hook
289
+ // can reach the day somebody adds an input called `workspace`.
290
+ const noPaths: ContractHookSignature = {
291
+ inputs: { restore_dir: { required: true } },
292
+ outputs: {},
293
+ };
294
+ expect(declaredPathInputs(noPaths, { restore_dir: '/tmp/x' })).toEqual([]);
295
+ });
296
+
297
+ test('a declared input this run did not receive is skipped', () => {
298
+ expect(declaredPathInputs(SIG, {})).toEqual([]);
299
+ });
300
+ });
301
+
302
+ describe('the recorded jail mode describes the HOST', () => {
303
+ test('an invocation with no module tree records nothing', async () => {
304
+ // Such a run is unjailable whatever the host can do, so recording it
305
+ // would overwrite a `jailed` record with an `unjailed` one — exactly the
306
+ // transition a self-monitor raises an alert on (design D8, task 4.4).
307
+ const store = join(mkdtempSync(join(tmpdir(), 'celilo-mode-')), 'mode.json');
308
+ const saved = process.env.CELILO_HOOK_JAIL_MODE_PATH;
309
+ process.env.CELILO_HOOK_JAIL_MODE_PATH = store;
310
+ try {
311
+ const { logger } = createCapturingLogger();
312
+ await executeHookScript(join(FIXTURES_DIR, 'void-hook.ts'), {
313
+ config: {},
314
+ secrets: {},
315
+ systems: [],
316
+ logger,
317
+ debug: false,
318
+ screenshotDir: '/tmp',
319
+ stateDir: '/tmp',
320
+ capabilities: {},
321
+ });
322
+ expect(readJailMode()).toBeUndefined();
323
+ expect(existsSync(store)).toBe(false);
324
+ } finally {
325
+ if (saved === undefined) delete process.env.CELILO_HOOK_JAIL_MODE_PATH;
326
+ else process.env.CELILO_HOOK_JAIL_MODE_PATH = saved;
327
+ }
328
+ });
329
+ });
330
+
250
331
  describe('resolveHookTimeouts', () => {
251
332
  test('no declaration: 60s total, 30s idle heuristic', () => {
252
333
  expect(resolveHookTimeouts(undefined, false)).toEqual({
@@ -24,16 +24,24 @@
24
24
  * records after celilo reported the deploy failed (celilo#1003).
25
25
  * - A hook's memory is its own process's, not celilo's heap.
26
26
  *
27
+ * **Stage 2 has landed and the child is JAILED where a backend exists.** The
28
+ * filesystem view is derived per run (`mount-set.ts`, design D9) and built with
29
+ * bubblewrap (`jail.ts`, design D8). A jailed hook reaching for celilo's master
30
+ * key gets `ENOENT` — the path is not denied, it is absent. Where no backend
31
+ * exists (a Mac today, task 4.8) the hook runs unjailed and the mode is
32
+ * RECORDED, so a host that quietly stops jailing is visible rather than silent.
33
+ *
27
34
  * Hooks still do NOT execute on the target machine. One that needs to touch a
28
35
  * target initiates SSH outbound itself, so anything it depends on (chromium,
29
36
  * system binaries, credentials) must be available on the celilo CLI host.
30
- * Stage 2 of the change above jails the filesystem and stage 3 scopes that
31
- * reachability; neither has landed.
37
+ * Stage 3 scopes that reachability and has not landed, which is why `~/.ssh`
38
+ * is still bound into the jail read-only.
32
39
  *
33
40
  * Execution function (Rule 10.1) - performs side effects (script execution)
34
41
  */
35
42
 
36
43
  import { existsSync, mkdirSync, readdirSync, rmdirSync, statSync } from 'node:fs';
44
+ import { homedir } from 'node:os';
37
45
  import { dirname, join, resolve } from 'node:path';
38
46
  import {
39
47
  type DeployedSystem,
@@ -57,6 +65,16 @@ import {
57
65
  createLineReader,
58
66
  deserializeError,
59
67
  } from './hook-protocol';
68
+ import {
69
+ type JailPlan,
70
+ detectJailBackend,
71
+ jailPolicy,
72
+ planJailedSpawn,
73
+ realpathRequest,
74
+ recordJailMode,
75
+ runtimeModulePathsFor,
76
+ } from './jail';
77
+ import { type DeclaredPathInput, type MountSetRequest, deriveMountSet } from './mount-set';
60
78
  import type { HookContext, HookDefinition, HookLogger, HookResult } from './types';
61
79
 
62
80
  /** Default total timeout: 60 seconds */
@@ -218,6 +236,37 @@ export function resolveHookScript(modulePath: string, scriptPath: string): strin
218
236
  return resolved;
219
237
  }
220
238
 
239
+ /**
240
+ * What the jail needs that only the caller knows.
241
+ *
242
+ * Everything else in a `MountSetRequest` the executor already holds or is: the
243
+ * writable directories are on the context, the socket belongs to the broker it
244
+ * just started, and the runtime and the shim are its own. These two are not.
245
+ * `modulePath` is the module's identity and `pathInputs` needs the contract
246
+ * signature, and neither reaches `executeHookScript` any other way.
247
+ *
248
+ * Absent means "do not jail this invocation" rather than "jail it with
249
+ * nothing" — an empty mount set is a hook that cannot read its own script.
250
+ */
251
+ export interface HookJailInputs {
252
+ /** The module's own tree, `mod.sourcePath`. Realpath'd here, not by the caller. */
253
+ readonly modulePath: string;
254
+ /** Contract-declared path inputs, paired with the values this run resolved. */
255
+ readonly pathInputs: readonly DeclaredPathInput[];
256
+ }
257
+
258
+ export interface ExecuteHookOptions {
259
+ /** Total timeout in milliseconds. */
260
+ timeoutMs?: number;
261
+ /**
262
+ * Kill after this much silence (no output of any kind from the child).
263
+ * Pass `timeoutMs` to disable the idle heuristic — see `IDLE_TIMEOUT_MS`.
264
+ */
265
+ idleTimeoutMs?: number;
266
+ /** What to jail this run with. Absent runs the hook unjailed. */
267
+ jail?: HookJailInputs;
268
+ }
269
+
221
270
  /**
222
271
  * Execute a hook script
223
272
  *
@@ -225,17 +274,17 @@ export function resolveHookScript(modulePath: string, scriptPath: string): strin
225
274
  *
226
275
  * @param scriptPath - Absolute path to the hook script
227
276
  * @param context - Hook context with config, secrets, logger, and inputs
228
- * @param timeoutMs - Total timeout in milliseconds
229
- * @param idleTimeoutMs - Kill after this much silence (no `ctx.logger` call).
230
- * Pass `timeoutMs` to disable the idle heuristic — see `IDLE_TIMEOUT_MS`.
277
+ * @param options - Timeouts and the jail inputs
231
278
  * @returns Hook result with outputs
232
279
  */
233
280
  export async function executeHookScript(
234
281
  scriptPath: string,
235
282
  context: HookContext,
236
- timeoutMs: number = DEFAULT_TIMEOUT_MS,
237
- idleTimeoutMs: number = IDLE_TIMEOUT_MS,
283
+ options: ExecuteHookOptions = {},
238
284
  ): Promise<Record<string, unknown>> {
285
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
286
+ const idleTimeoutMs = options.idleTimeoutMs ?? IDLE_TIMEOUT_MS;
287
+
239
288
  if (!existsSync(scriptPath)) {
240
289
  throw new Error(`Hook script not found: ${scriptPath}`);
241
290
  }
@@ -261,8 +310,22 @@ export async function executeHookScript(
261
310
  });
262
311
 
263
312
  try {
313
+ const jail = planJailedSpawn(
314
+ [process.execPath, HOOK_RUNNER_PATH],
315
+ options.jail &&
316
+ deriveMountSet(realpathRequest(jailRequest(options.jail, context, broker.socketPath))),
317
+ detectJailBackend(),
318
+ jailPolicy(),
319
+ );
320
+ reportJail(jail, logger);
321
+ // Only a real module hook says anything about whether this HOST jails. An
322
+ // invocation with no module tree is unjailable whatever the host can do,
323
+ // and recording it would overwrite a `jailed` record with an `unjailed`
324
+ // one — which is precisely the transition task 4.4 raises an alert on.
325
+ if (options.jail) recordJailMode(jail);
326
+
264
327
  const child = Bun.spawn({
265
- cmd: [process.execPath, HOOK_RUNNER_PATH],
328
+ cmd: [...jail.cmd],
266
329
  env: hookChildEnv(broker.socketPath),
267
330
  stdout: 'pipe',
268
331
  stderr: 'pipe',
@@ -376,6 +439,64 @@ export function hookChildEnv(socketPath: string): Record<string, string> {
376
439
  return env;
377
440
  }
378
441
 
442
+ /**
443
+ * Assemble this run's mount-set request (design D9).
444
+ *
445
+ * Planning function (Rule 10.4) — pure. It names every path the jail will
446
+ * bind, and reading it is how you answer "why can the hook not see X".
447
+ *
448
+ * The two writable directories are carved out of the module's own read-only
449
+ * tree, which is the one exception to D9's sentence that the tree is bound
450
+ * read-only. `state/` and `screenshots/<run>` both sit INSIDE `<store>/<id>`,
451
+ * and bubblewrap resolves that by order: a later `--bind` wins over an earlier
452
+ * `--ro-bind`. The derivation emits them in that order deliberately.
453
+ */
454
+ function jailRequest(
455
+ inputs: HookJailInputs,
456
+ context: HookContext,
457
+ socketPath: string,
458
+ ): MountSetRequest {
459
+ return {
460
+ modulePath: inputs.modulePath,
461
+ stateDir: context.stateDir,
462
+ screenshotDir: context.screenshotDir,
463
+ socketDir: dirname(socketPath),
464
+ runtimePath: process.execPath,
465
+ runnerPath: HOOK_RUNNER_PATH,
466
+ runtimeModulePaths: runtimeModulePathsFor(HOOK_RUNNER_PATH),
467
+ pathInputs: inputs.pathInputs,
468
+ // Stage 2 only. `remote.ts` still runs inside the hook and needs the key;
469
+ // stage 3 brokers those calls and drops this row (D9, D12). Dropping it
470
+ // early hardens nothing — it stops every hook reaching its own systems.
471
+ sshDir: join(homedir(), '.ssh'),
472
+ };
473
+ }
474
+
475
+ /**
476
+ * Say what the jail did, once per run, at a level that matches how surprising
477
+ * it is.
478
+ *
479
+ * `skipped` is a warning rather than debug output on purpose. A dropped
480
+ * `/lib64` on arm64 is routine; a dropped contract input means the hook is
481
+ * about to write into the run's private tmpfs and report success over a
482
+ * directory that is discarded when it exits (task 4.2j). Both look identical
483
+ * here, so the line names the paths and lets a reader tell them apart.
484
+ */
485
+ function reportJail(plan: JailPlan, logger: HookLogger): void {
486
+ if (plan.mode === 'jailed') {
487
+ if (plan.skipped.length > 0) {
488
+ logger.warn(
489
+ `Hook jail: ${plan.skipped.length} mount(s) absent on this host and dropped: ${plan.skipped.join(', ')}`,
490
+ );
491
+ }
492
+ return;
493
+ }
494
+ // Not a warning. D8 is explicit that a steady-state unjailed host (a Mac) is
495
+ // not an event, and a per-invocation warning on a fleet that deploys often
496
+ // is noise that gets filtered. The recorded mode is what raises the alarm.
497
+ if (plan.reason) logger.info(`Hook jail unavailable: ${plan.reason}`);
498
+ }
499
+
379
500
  /** Read a piped stream line by line into the logger. */
380
501
  async function forwardStream(
381
502
  stream: ReadableStream<Uint8Array>,
@@ -492,6 +613,36 @@ export function checkRequiredCapabilities(
492
613
  return lines.join(' ');
493
614
  }
494
615
 
616
+ /**
617
+ * The contract's declared path inputs, paired with the values this run got.
618
+ *
619
+ * Policy function (Rule 10.1) — pure.
620
+ *
621
+ * Walks the DECLARATION, never the values. A heuristic over the input names
622
+ * ("does it end in `_dir`?") silently changes what a hook can reach the day
623
+ * somebody adds an input called `workspace`, and the symptom is an `ENOENT` on
624
+ * a path that visibly exists on the box. `ContractField.path` says which
625
+ * fields are paths and at what access, and this reads only that.
626
+ *
627
+ * The corollary is that an undeclared path input contributes nothing to the
628
+ * jail, so the hook cannot reach it. That is the correct outcome and it is why
629
+ * `db_path` had to be declared (celilo#1118, task 4.2h): the contract lying
630
+ * about what a backup hook receives now costs the hook the file.
631
+ */
632
+ export function declaredPathInputs(
633
+ signature: ContractHookSignature,
634
+ inputs: Record<string, unknown>,
635
+ ): DeclaredPathInput[] {
636
+ const declared: DeclaredPathInput[] = [];
637
+ for (const [name, field] of Object.entries(signature.inputs)) {
638
+ if (!field.path) continue;
639
+ const value = inputs[name];
640
+ if (typeof value !== 'string' || value === '') continue;
641
+ declared.push({ name, value, access: field.path.access });
642
+ }
643
+ return declared;
644
+ }
645
+
495
646
  /**
496
647
  * Find the most recently created screenshot in a directory
497
648
  *
@@ -691,7 +842,11 @@ export async function invokeHook(
691
842
  // Execute
692
843
  try {
693
844
  logger.info(`Executing hook: ${hookName}`);
694
- const outputs = await executeHookScript(scriptPath, context, timeoutMs, idleTimeoutMs);
845
+ const outputs = await executeHookScript(scriptPath, context, {
846
+ timeoutMs,
847
+ idleTimeoutMs,
848
+ jail: { modulePath, pathInputs: declaredPathInputs(signature, inputs) },
849
+ });
695
850
 
696
851
  // Validate outputs against the contract signature
697
852
  const outputError = validateHookOutputs(signature, outputs);
@@ -0,0 +1,173 @@
1
+ /**
2
+ * The live half of the hook jail: a hook that genuinely cannot reach a path
3
+ * (design D9, tasks 4.9 and 4.2j).
4
+ *
5
+ * **This suite needs a real jail and SKIPS without one, loudly.** A Mac has no
6
+ * backend until task 4.8, and neither does a runner with no bubblewrap, so the
7
+ * skip is the common case on a development box. It prints the reason the probe
8
+ * gave rather than passing quietly — CLAUDE.md's whole point about a check that
9
+ * cannot reach its subject is that silence and success look identical.
10
+ *
11
+ * Two properties, and they fail in opposite directions:
12
+ *
13
+ * - **Unreachability, asserted as unreachability.** bubblewrap removes the
14
+ * path (`ENOENT`), `sandbox-exec` denies it (`EPERM`), and both satisfy
15
+ * D9. So the assertion is "the read did not succeed", never a match on an
16
+ * errno — a test pinned to `ENOENT` would go red on macOS for a jail that
17
+ * was working perfectly.
18
+ *
19
+ * - **The staged input survived the tmpfs.** `/tmp` is a fresh tmpfs mounted
20
+ * FIRST, and every staged contract input lives under `os.tmpdir()`. Mount
21
+ * the tmpfs after them and they vanish — and a hook whose `backup_dir` is
22
+ * silently an empty tmpfs directory writes into it, returns success, and
23
+ * produces a backup containing NOTHING. It is found at restore. So this
24
+ * asserts the bytes are on disk in the PARENT afterwards, never that the
25
+ * hook exited zero (task 4.2j).
26
+ */
27
+
28
+ import { describe, expect, test } from 'bun:test';
29
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
30
+ import { tmpdir } from 'node:os';
31
+ import { join, resolve } from 'node:path';
32
+ import { executeHookScript } from './executor';
33
+ import { detectJailBackend } from './jail';
34
+ import { createCapturingLogger } from './logger';
35
+ import type { HookContext } from './types';
36
+
37
+ const PROBE_HOOK = resolve(__dirname, 'test-fixtures/jail-probe-hook.ts');
38
+
39
+ interface Probe {
40
+ succeeded: boolean;
41
+ detail: string;
42
+ }
43
+
44
+ const availability = detectJailBackend();
45
+ const jailed = availability.backend !== 'none';
46
+
47
+ interface Rig {
48
+ outputs: {
49
+ planted_secret: Probe;
50
+ sibling_write: Probe;
51
+ state_write: Probe;
52
+ staged_write: Probe;
53
+ };
54
+ stagedInput: string;
55
+ siblingFile: string;
56
+ cleanup: () => void;
57
+ }
58
+
59
+ /**
60
+ * Lay out a module store the way celilo does, run the probe hook against it,
61
+ * and hand back both the hook's report and the paths so the parent can check
62
+ * the disk rather than the report.
63
+ */
64
+ async function runProbe(): Promise<Rig> {
65
+ const scratch = mkdtempSync(join(tmpdir(), 'celilo-jail-live-'));
66
+ const store = join(scratch, 'modules');
67
+ const modulePath = join(store, 'jail-probe');
68
+ const stateDir = join(modulePath, 'state');
69
+ const screenshotDir = join(modulePath, 'screenshots', 'run');
70
+ mkdirSync(join(modulePath, 'scripts'), { recursive: true });
71
+ mkdirSync(stateDir, { recursive: true });
72
+ mkdirSync(screenshotDir, { recursive: true });
73
+
74
+ // A sibling module, one `..` away from the hook's own tree.
75
+ const sibling = join(store, 'technitium');
76
+ mkdirSync(sibling, { recursive: true });
77
+ const siblingFile = join(sibling, 'trespassed');
78
+
79
+ // celilo's master key, outside the store entirely. Planted rather than real:
80
+ // the operator's key is never read (CLAUDE.md, never test against live data).
81
+ const plantedSecret = join(scratch, 'master.key');
82
+ writeFileSync(plantedSecret, 'not-the-real-key');
83
+
84
+ // A staged contract input, under os.tmpdir() exactly as `stagingDirFor`
85
+ // produces. This is the path the tmpfs would erase.
86
+ const stagedInput = mkdtempSync(join(tmpdir(), 'celilo-jail-staged-'));
87
+
88
+ const { logger } = createCapturingLogger();
89
+ const context: HookContext = {
90
+ config: {
91
+ planted_secret: plantedSecret,
92
+ sibling_file: siblingFile,
93
+ staged_input: stagedInput,
94
+ },
95
+ secrets: {},
96
+ systems: [],
97
+ logger,
98
+ debug: false,
99
+ screenshotDir,
100
+ stateDir,
101
+ capabilities: {},
102
+ };
103
+
104
+ const outputs = (await executeHookScript(PROBE_HOOK, context, {
105
+ timeoutMs: 60_000,
106
+ idleTimeoutMs: 60_000,
107
+ jail: {
108
+ modulePath,
109
+ pathInputs: [{ name: 'staged_input', value: stagedInput, access: 'write' }],
110
+ },
111
+ })) as unknown as Rig['outputs'];
112
+
113
+ return {
114
+ outputs,
115
+ stagedInput,
116
+ siblingFile,
117
+ cleanup: () => {
118
+ rmSync(scratch, { recursive: true, force: true });
119
+ rmSync(stagedInput, { recursive: true, force: true });
120
+ },
121
+ };
122
+ }
123
+
124
+ describe.skipIf(!jailed)(`the jail is real (backend: ${availability.backend})`, () => {
125
+ test('a hook cannot reach celilo’s master key, or a sibling module', async () => {
126
+ const rig = await runProbe();
127
+ try {
128
+ const { planted_secret, sibling_write, state_write, staged_write } = rig.outputs;
129
+ console.log(['', 'jail probe:', JSON.stringify(rig.outputs, null, 2)].join('\n'));
130
+
131
+ // Unreachability, not an errno. Under bubblewrap the message is ENOENT
132
+ // and under sandbox-exec it is EPERM; asserting either one would make
133
+ // this suite red on a platform whose jail works.
134
+ expect(planted_secret.succeeded).toBe(false);
135
+ expect(sibling_write.succeeded).toBe(false);
136
+ // And the parent's own view: the write did not land by another route.
137
+ expect(existsSync(rig.siblingFile)).toBe(false);
138
+
139
+ // The carve-out. `state/` is inside the read-only module tree and is
140
+ // bound read-write on top of it; if the ordering were wrong this is the
141
+ // assertion that would catch it.
142
+ expect(state_write.succeeded).toBe(true);
143
+ expect(staged_write.succeeded).toBe(true);
144
+ } finally {
145
+ rig.cleanup();
146
+ }
147
+ }, 90_000);
148
+
149
+ test('a staged contract input survives the /tmp tmpfs (task 4.2j)', async () => {
150
+ const rig = await runProbe();
151
+ try {
152
+ // The hook reported success above. That is exactly the signal that is
153
+ // worthless here: a write into a private tmpfs succeeds and is gone.
154
+ // Read the bytes from the parent.
155
+ const produced = join(rig.stagedInput, 'produced');
156
+ expect(existsSync(produced)).toBe(true);
157
+ expect(readFileSync(produced, 'utf-8').length).toBeGreaterThan(0);
158
+ } finally {
159
+ rig.cleanup();
160
+ }
161
+ }, 90_000);
162
+ });
163
+
164
+ describe.skipIf(jailed)('no jail on this host', () => {
165
+ test('says so, rather than reporting a pass it did not earn', () => {
166
+ // Not an assertion about the product. It is the line that stops a green
167
+ // run on a Mac reading as "the jail was proven".
168
+ console.log(
169
+ `\nhook jail: SKIPPED the live suite — ${availability.reason ?? 'no backend'}\nThese properties are proven on Linux with bubblewrap. The hermetic half runs everywhere (jail.test.ts).`,
170
+ );
171
+ expect(availability.backend).toBe('none');
172
+ });
173
+ });