@celilo/cli 1.12.0 → 1.13.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 (47) hide show
  1. package/CELILO_CORE_MODULES.md +2 -1
  2. package/CELILO_SUBSYSTEMS.md +17 -2
  3. package/package.json +3 -3
  4. package/src/cli/commands/alerts-list.ts +16 -1
  5. package/src/cli/commands/backup-list.test.ts +82 -1
  6. package/src/cli/commands/backup-list.ts +113 -4
  7. package/src/cli/commands/console.ts +122 -0
  8. package/src/cli/commands/module-list.ts +3 -41
  9. package/src/cli/completion.ts +5 -0
  10. package/src/cli/index.ts +25 -1
  11. package/src/console/closure.test.ts +246 -0
  12. package/src/console/closure.ts +208 -0
  13. package/src/console/control-plane-boundary.test.ts +75 -0
  14. package/src/console/projection.test.ts +231 -0
  15. package/src/console/projection.ts +327 -0
  16. package/src/db/schema.ts +19 -14
  17. package/src/hooks/broker.test.ts +4 -6
  18. package/src/hooks/executor.test.ts +85 -4
  19. package/src/hooks/executor.ts +164 -9
  20. package/src/hooks/hook-jail-unreachability.test.ts +173 -0
  21. package/src/hooks/hook-state-dir.test.ts +14 -2
  22. package/src/hooks/hook-timeout.test.ts +2 -4
  23. package/src/hooks/hook-trespass.test.ts +50 -5
  24. package/src/hooks/jail.test.ts +370 -0
  25. package/src/hooks/jail.ts +491 -0
  26. package/src/hooks/mount-set.ts +24 -0
  27. package/src/hooks/test-fixtures/jail-probe-hook.ts +59 -0
  28. package/src/manifest/schema.ts +35 -0
  29. package/src/manifest/validate.test.ts +142 -0
  30. package/src/manifest/validate.ts +101 -0
  31. package/src/module/import.test.ts +116 -0
  32. package/src/module/import.ts +73 -1
  33. package/src/module/packaging/audit.ts +103 -1
  34. package/src/module/packaging/classify-module-path.test.ts +36 -0
  35. package/src/module/packaging/package-rules.ts +18 -0
  36. package/src/policy/capability-shape-baseline.ts +8 -0
  37. package/src/policy/module-business-baseline.ts +12 -0
  38. package/src/services/alerting/observed-health.ts +71 -0
  39. package/src/services/api-principal-enrolment.test.ts +179 -0
  40. package/src/services/api-principal-enrolment.ts +103 -0
  41. package/src/services/audit/backups.ts +10 -1
  42. package/src/services/backup-metadata.ts +19 -11
  43. package/src/services/consumer-cleanup.ts +31 -5
  44. package/src/services/instance-ops.test.ts +302 -0
  45. package/src/services/instance-ops.ts +292 -0
  46. package/src/services/module-instances.test.ts +428 -42
  47. package/src/services/module-instances.ts +219 -26
@@ -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
+ });
@@ -1,7 +1,7 @@
1
1
  import { describe, expect, test } from 'bun:test';
2
- import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
2
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync } from 'node:fs';
3
3
  import { tmpdir } from 'node:os';
4
- import { join } from 'node:path';
4
+ import { join, resolve } from 'node:path';
5
5
  import { moduleStateDir } from '@celilo/capabilities';
6
6
  import { classifyModulePath } from '../module/packaging/package-rules';
7
7
  import { invokeHook } from './executor';
@@ -25,9 +25,21 @@ const silent: HookLogger = {
25
25
  success() {},
26
26
  };
27
27
 
28
+ /** The workspace copy of the package every module bundles. */
29
+ const CAPABILITIES_PACKAGE = resolve(import.meta.dir, '../../../../packages/capabilities');
30
+
28
31
  function scratchModule(script: string): string {
29
32
  const root = mkdtempSync(join(tmpdir(), 'celilo-statedir-'));
30
33
  Bun.write(join(root, 'hook.ts'), script);
34
+ // A real module in the store carries its OWN bundled @celilo/capabilities
35
+ // (modules run their bundled copy, celilo#173), and the hook jail binds the
36
+ // module's tree and nothing above it. This fixture used to resolve the
37
+ // import through the test runner's own tree, which worked only because hooks
38
+ // ran unjailed; `--chdir` into the module root removed that accident.
39
+ // Linking it here is what makes the fixture a module rather than a loose
40
+ // script.
41
+ mkdirSync(join(root, 'node_modules', '@celilo'), { recursive: true });
42
+ symlinkSync(CAPABILITIES_PACKAGE, join(root, 'node_modules', '@celilo', 'capabilities'));
31
43
  return root;
32
44
  }
33
45
 
@@ -74,8 +74,7 @@ describe('hook timeout is a kill, not a race', () => {
74
74
  executeHookScript(
75
75
  join(FIXTURES, 'runaway-hook.ts'),
76
76
  contextFor({ sleep_ms: 1500, marker_path: marker }),
77
- 400,
78
- 400,
77
+ { timeoutMs: 400, idleTimeoutMs: 400 },
79
78
  ),
80
79
  ).rejects.toThrow(/timeout/i);
81
80
 
@@ -92,8 +91,7 @@ describe('hook timeout is a kill, not a race', () => {
92
91
  executeHookScript(
93
92
  join(FIXTURES, 'sigterm-ignoring-hook.ts'),
94
93
  contextFor({ sleep_ms: 6000, marker_path: marker }),
95
- 400,
96
- 400,
94
+ { timeoutMs: 400, idleTimeoutMs: 400 },
97
95
  ),
98
96
  ).rejects.toThrow(/timeout/i);
99
97
 
@@ -22,9 +22,15 @@
22
22
 
23
23
  import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
24
24
  import { execFileSync } from 'node:child_process';
25
- import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
25
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
26
26
  import { tmpdir } from 'node:os';
27
27
  import { dirname, join, resolve } from 'node:path';
28
+ import {
29
+ applyFallbacksToPackageJson,
30
+ isPublished,
31
+ listUnpublishedWorkspacePackages,
32
+ planFallbacks,
33
+ } from '../../../../scripts/workspace-fallback';
28
34
  import { executeHookScript, hookChildEnv } from './executor';
29
35
  import { createCapturingLogger } from './logger';
30
36
  import type { HookContext } from './types';
@@ -75,9 +81,45 @@ let realHome: string | undefined;
75
81
  function ensureFixtureInstalled(): void {
76
82
  const scriptsDir = dirname(TRESPASS_SCRIPT);
77
83
  if (existsSync(join(scriptsDir, 'node_modules', '@celilo', 'capabilities'))) return;
78
- // `process.execPath`, not `bun` on PATH: this is the bun already running the
79
- // suite, so it works wherever the suite does.
80
- execFileSync(process.execPath, ['install'], { cwd: scriptsDir, stdio: 'pipe' });
84
+
85
+ // On a version-packages PR the pin names a version that CANNOT be on the
86
+ // registry yet, because only merging that PR lets it publish. So a plain
87
+ // install here fails with `No version matching "^3.3.0" found`, and the
88
+ // version PR can never go green on its own.
89
+ //
90
+ // This is celilo#642 exactly, and check-modules.sh already solves it. Reuse
91
+ // that rather than resolving from the workspace unconditionally: doing it
92
+ // unconditionally would check the fixture against a copy it does not bundle,
93
+ // which is the celilo#173 property this module's scripts/ dir is kept out of
94
+ // the root workspace globs to preserve. The fallback fires only when the pin
95
+ // names a version the registry lacks AND the workspace holds exactly that
96
+ // version — where the workspace IS the artifact about to be published, same
97
+ // bytes by definition.
98
+ //
99
+ // The probe is scoped to this module's own dependencies: packages it does not
100
+ // depend on are reported published without asking the network, so this costs
101
+ // one registry call rather than one per publishable workspace package.
102
+ const pkgPath = join(scriptsDir, 'package.json');
103
+ const original = readFileSync(pkgPath, 'utf-8');
104
+ const dependencies =
105
+ (JSON.parse(original) as { dependencies?: Record<string, string> }).dependencies ?? {};
106
+ const unpublished = listUnpublishedWorkspacePackages((name, version) =>
107
+ name in dependencies ? isPublished(name, version) : true,
108
+ );
109
+ const fallbacks = planFallbacks(dependencies, unpublished);
110
+ if (fallbacks.length > 0)
111
+ writeFileSync(pkgPath, applyFallbacksToPackageJson(original, fallbacks));
112
+
113
+ try {
114
+ // `process.execPath`, not `bun` on PATH: this is the bun already running the
115
+ // suite, so it works wherever the suite does.
116
+ execFileSync(process.execPath, ['install'], { cwd: scriptsDir, stdio: 'pipe' });
117
+ } finally {
118
+ // Restore even when the install failed. A `file:` pin left in the tree
119
+ // points the fixture at one machine's checkout and would be swept up by the
120
+ // next `git add -A` — the same reason check-modules.sh restores from a trap.
121
+ if (fallbacks.length > 0) writeFileSync(pkgPath, original);
122
+ }
81
123
  }
82
124
 
83
125
  beforeAll(() => {
@@ -121,7 +163,10 @@ async function runTrespass(): Promise<{ report: TrespassReport; lines: string[]
121
163
  capabilities: {},
122
164
  };
123
165
 
124
- const outputs = await executeHookScript(TRESPASS_SCRIPT, context, 60_000, 60_000);
166
+ const outputs = await executeHookScript(TRESPASS_SCRIPT, context, {
167
+ timeoutMs: 60_000,
168
+ idleTimeoutMs: 60_000,
169
+ });
125
170
  return {
126
171
  report: outputs as unknown as TrespassReport,
127
172
  lines: messages.map((m) => m.message),