@celilo/cli 1.11.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 (50) 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/commands/module-publish.ts +2 -0
  10. package/src/cli/completion.ts +5 -0
  11. package/src/cli/index.ts +25 -1
  12. package/src/console/closure.test.ts +246 -0
  13. package/src/console/closure.ts +208 -0
  14. package/src/console/control-plane-boundary.test.ts +75 -0
  15. package/src/console/projection.test.ts +231 -0
  16. package/src/console/projection.ts +327 -0
  17. package/src/db/schema.ts +19 -14
  18. package/src/hooks/broker.test.ts +4 -6
  19. package/src/hooks/executor.test.ts +85 -4
  20. package/src/hooks/executor.ts +164 -9
  21. package/src/hooks/hook-jail-unreachability.test.ts +173 -0
  22. package/src/hooks/hook-state-dir.test.ts +14 -2
  23. package/src/hooks/hook-timeout.test.ts +2 -4
  24. package/src/hooks/hook-trespass.test.ts +50 -5
  25. package/src/hooks/jail.test.ts +370 -0
  26. package/src/hooks/jail.ts +491 -0
  27. package/src/hooks/mount-set.ts +24 -0
  28. package/src/hooks/test-fixtures/jail-probe-hook.ts +59 -0
  29. package/src/manifest/icon-schema.test.ts +48 -0
  30. package/src/manifest/schema.ts +92 -0
  31. package/src/manifest/validate.test.ts +142 -0
  32. package/src/manifest/validate.ts +101 -0
  33. package/src/module/import.test.ts +116 -0
  34. package/src/module/import.ts +73 -1
  35. package/src/module/packaging/audit.ts +103 -1
  36. package/src/module/packaging/classify-module-path.test.ts +36 -0
  37. package/src/module/packaging/package-rules.ts +18 -0
  38. package/src/policy/capability-shape-baseline.ts +8 -0
  39. package/src/policy/module-business-baseline.ts +12 -0
  40. package/src/registry/client.ts +9 -0
  41. package/src/services/alerting/observed-health.ts +71 -0
  42. package/src/services/api-principal-enrolment.test.ts +179 -0
  43. package/src/services/api-principal-enrolment.ts +103 -0
  44. package/src/services/audit/backups.ts +10 -1
  45. package/src/services/backup-metadata.ts +19 -11
  46. package/src/services/consumer-cleanup.ts +31 -5
  47. package/src/services/instance-ops.test.ts +302 -0
  48. package/src/services/instance-ops.ts +292 -0
  49. package/src/services/module-instances.test.ts +428 -42
  50. package/src/services/module-instances.ts +219 -26
@@ -0,0 +1,491 @@
1
+ /**
2
+ * The hook jail's caller (openspec/changes/hook-process-boundary, D8 and D9).
3
+ *
4
+ * `mount-set.ts` computes WHAT a hook may see. This file decides whether a jail
5
+ * is available at all, turns that computation into a command line, and records
6
+ * which of the two happened. It is the half that touches the machine, kept out
7
+ * of the derivation so the derivation stays hermetic.
8
+ *
9
+ * Three things live here and they answer three different questions:
10
+ *
11
+ * - `detectJailBackend()` — CAN this host jail? Measured by running
12
+ * bubblewrap, never by looking for the binary. D8's table records four
13
+ * distinct denials that all leave `bwrap` sitting on disk.
14
+ * - `planJailedSpawn()` — pure. Given a backend, a policy and a mount set,
15
+ * what command does celilo spawn? (Rule 10.4.)
16
+ * - `recordJailMode()` — WHICH happened, written down. D8 is explicit that
17
+ * the mode is state and not a log line: a per-invocation warning on a fleet
18
+ * that deploys often is noise, noise gets filtered, and filtered is
19
+ * indistinguishable from absent. The row that matters is a host that used
20
+ * to jail and has stopped, and you cannot see a transition in a log nobody
21
+ * reads.
22
+ */
23
+
24
+ import { execFileSync } from 'node:child_process';
25
+ import type { Dirent } from 'node:fs';
26
+ import {
27
+ existsSync,
28
+ mkdirSync,
29
+ readFileSync,
30
+ readdirSync,
31
+ realpathSync,
32
+ renameSync,
33
+ statSync,
34
+ writeFileSync,
35
+ } from 'node:fs';
36
+ import { hostname } from 'node:os';
37
+ import { dirname, join } from 'node:path';
38
+ import { getDataDir } from '../config/paths';
39
+ import { type MountSet, type MountSetRequest, toBwrapArgs } from './mount-set';
40
+
41
+ /** Which jail celilo can build here. `none` means the hook runs unjailed. */
42
+ export type JailBackend = 'bubblewrap' | 'none';
43
+
44
+ /**
45
+ * What the operator asked for. `CELILO_HOOK_JAIL`, and the default is `auto`.
46
+ *
47
+ * `required` is D8's end state, reached as an explicit act once stage 2 has
48
+ * been proven on a host: an unavailable jail becomes a hard failure rather
49
+ * than a recorded fact. `off` is the escape hatch for the reverse case — a
50
+ * hook that has not yet been walked against the jail (task 4.13) and needs to
51
+ * run today.
52
+ */
53
+ export type JailPolicy = 'auto' | 'off' | 'required';
54
+
55
+ /** Whether the hook that just ran was jailed. The recorded state (D8). */
56
+ export type JailMode = 'jailed' | 'unjailed';
57
+
58
+ export interface JailAvailability {
59
+ readonly backend: JailBackend;
60
+ /**
61
+ * Why there is no backend, in one sentence an operator can act on.
62
+ * Absent when there is one. `celilo system doctor` surfaces it (task 4.6).
63
+ */
64
+ readonly reason?: string;
65
+ }
66
+
67
+ export interface JailPlan {
68
+ /** The command celilo spawns, jail wrapper included. */
69
+ readonly cmd: readonly string[];
70
+ readonly mode: JailMode;
71
+ readonly backend: JailBackend;
72
+ readonly reason?: string;
73
+ /**
74
+ * Mount rows the derivation asked for that do not exist on this host.
75
+ *
76
+ * bubblewrap fails the whole jail on a bind whose SOURCE is missing, and
77
+ * several rows are legitimately absent: `/lib64` does not exist on arm64,
78
+ * `<module>/generated` only appears once celilo has generated something, and
79
+ * `~/.ssh` need not exist at all. Dropping them is the caller's job rather
80
+ * than the derivation's, which is why they are reported rather than silently
81
+ * filtered — a contract input landing here is a real defect and this is
82
+ * where it becomes visible.
83
+ */
84
+ readonly skipped: readonly string[];
85
+ }
86
+
87
+ /**
88
+ * Namespace flags, shared by the probe and the real spawn so they cannot
89
+ * drift.
90
+ *
91
+ * A probe that clears a weaker bar than the spawn is the failure CLAUDE.md
92
+ * names: a correct assertion about the wrong subject. If any flag here is
93
+ * denied, the probe fails and celilo records `unjailed` instead of discovering
94
+ * it one hook at a time.
95
+ *
96
+ * **`--unshare-net` is deliberately absent.** D9 says the network is not
97
+ * namespaced; D12 scopes reachability by withholding the credential instead.
98
+ *
99
+ * `--unshare-pid` needs a fresh `/proc` or the jail shows the host's process
100
+ * table, and `/proc/1/root` is a well-worn way to read out of one. It also
101
+ * makes the kill in D7 total: `bwrap` is pid 1 inside the namespace, and the
102
+ * kernel reaps every process in a pid namespace whose init dies.
103
+ *
104
+ * `--new-session` is NOT here. It defends against TIOCSTI injection into a
105
+ * controlling terminal, and the child is spawned with `stdout: 'pipe'` and no
106
+ * tty, so there is nothing to inject into.
107
+ */
108
+ const JAIL_NAMESPACE_ARGS = [
109
+ '--unshare-user',
110
+ '--unshare-ipc',
111
+ '--unshare-pid',
112
+ '--unshare-uts',
113
+ // The one unshare that legitimately may be unavailable on an older kernel.
114
+ '--unshare-cgroup-try',
115
+ '--die-with-parent',
116
+ '--proc',
117
+ '/proc',
118
+ '--dev',
119
+ '/dev',
120
+ ] as const;
121
+
122
+ /**
123
+ * The jail builder.
124
+ *
125
+ * Resolved from `PATH` rather than pinned to `/usr/bin/bwrap`. The AppArmor
126
+ * profile D8 ships attaches BY PATH, so a `bwrap` found somewhere else carries
127
+ * no profile — but that case fails the probe below rather than passing
128
+ * silently, and celilo then records `unjailed` with the parser's own message.
129
+ * A visible wrong answer is worth more than a pinned path that is wrong on a
130
+ * distribution nobody tested.
131
+ */
132
+ const BWRAP = 'bwrap';
133
+
134
+ /** How long to wait for the probe before calling the backend unavailable. */
135
+ const PROBE_TIMEOUT_MS = 10_000;
136
+
137
+ const JAIL_MODE_FILE = 'hook-jail-mode.json';
138
+
139
+ /** Cleared by nothing: a host does not gain a jail mid-process. */
140
+ let probed: JailAvailability | undefined;
141
+
142
+ /**
143
+ * Can this host build a jail? Measured, cached for the process.
144
+ *
145
+ * The probe RUNS bubblewrap, with the same namespace flags the real spawn
146
+ * uses, against a whole-filesystem bind. Presence is not the question: D8
147
+ * measured four distinct denials — a missing `CAP_SYS_ADMIN`, a seccomp
148
+ * filter, an AppArmor policy, and Docker's masked `/proc` — and every one of
149
+ * them leaves the binary exactly where it was.
150
+ *
151
+ * `bun` is the command because it is the one binary guaranteed to be here: we
152
+ * are running in it. A probe that execs `/bin/true` fails for a missing
153
+ * `/bin/true` and reads exactly like a denied namespace.
154
+ */
155
+ export function detectJailBackend(): JailAvailability {
156
+ if (!probed) probed = probeJailBackend();
157
+ return probed;
158
+ }
159
+
160
+ function probeJailBackend(): JailAvailability {
161
+ if (process.platform === 'darwin') {
162
+ // `sandbox-exec` is measured working (D8) but the backend that generates
163
+ // its profile is task 4.8 and is not built. Claiming macOS is jailed
164
+ // because the tool exists would be the fail-open jail D8 calls worse than
165
+ // no jail at all.
166
+ return {
167
+ backend: 'none',
168
+ reason:
169
+ 'macOS has no hook jail yet: the sandbox-exec backend is designed but not built (hook-process-boundary task 4.8). Hooks run unjailed on this host.',
170
+ };
171
+ }
172
+ if (process.platform !== 'linux') {
173
+ return {
174
+ backend: 'none',
175
+ reason: `No hook jail backend exists for platform '${process.platform}'. Hooks run unjailed on this host.`,
176
+ };
177
+ }
178
+
179
+ try {
180
+ execFileSync(
181
+ BWRAP,
182
+ [...JAIL_NAMESPACE_ARGS, '--ro-bind', '/', '/', '--', process.execPath, '--version'],
183
+ { stdio: 'ignore', timeout: PROBE_TIMEOUT_MS },
184
+ );
185
+ return { backend: 'bubblewrap' };
186
+ } catch (error) {
187
+ return { backend: 'none', reason: unavailableReason(error) };
188
+ }
189
+ }
190
+
191
+ /**
192
+ * Turn the probe's failure into something an operator can act on.
193
+ *
194
+ * Deliberately reads the FAILURE and not a message: D8 records one string
195
+ * (`setting up uid map: Permission denied`) arriving from two unrelated
196
+ * causes, which is why the guidance names both rather than guessing.
197
+ */
198
+ function unavailableReason(error: unknown): string {
199
+ const spawnFailure = (error as { code?: string } | null)?.code;
200
+ if (spawnFailure === 'ENOENT') {
201
+ return 'bubblewrap is not installed, so hooks run unjailed. Install it (`apt install bubblewrap`) and re-run.';
202
+ }
203
+ return [
204
+ 'bubblewrap is installed but could not build a namespace, so hooks run unjailed.',
205
+ 'On Ubuntu 24.04 this is kernel.apparmor_restrict_unprivileged_userns=1 refusing a user namespace to an unprofiled binary;',
206
+ 'celilo ships /etc/apparmor.d/celilo-hook-jail to grant it, so check that the profile loaded (`apparmor_parser -Q --skip-cache /etc/apparmor.d/celilo-hook-jail`).',
207
+ 'Inside a container it is more likely a dropped capability or a masked /proc.',
208
+ ].join(' ');
209
+ }
210
+
211
+ /**
212
+ * What the operator asked for, from `CELILO_HOOK_JAIL`.
213
+ *
214
+ * Fails fast on a value it does not know (Rule 4.2). A typo'd
215
+ * `CELILO_HOOK_JAIL=requried` that silently meant `auto` would read as the
216
+ * jail being enforced when it is not, which is the one mistake this variable
217
+ * exists to prevent.
218
+ */
219
+ export function jailPolicy(): JailPolicy {
220
+ const raw = process.env.CELILO_HOOK_JAIL;
221
+ if (raw === undefined || raw === '') return 'auto';
222
+ if (raw === 'auto' || raw === 'off' || raw === 'required') return raw;
223
+ throw new Error(
224
+ `CELILO_HOOK_JAIL='${raw}' is not a hook jail policy. Use 'auto' (jail when a backend is available), 'required' (an unavailable jail is a hard failure), or 'off'.`,
225
+ );
226
+ }
227
+
228
+ /**
229
+ * Resolve every path in a mount-set request through the filesystem (task 4.2k).
230
+ *
231
+ * `deriveMountSet` is pure and says so: it resolves lexically, which cannot
232
+ * follow a symlink. Two reasons the caller has to.
233
+ *
234
+ * `modulePath` is `mod.sourcePath` out of the database, and a database
235
+ * restored from another box carries that box's absolute paths (ISS-0052,
236
+ * `restore-from-file.ts`).
237
+ *
238
+ * And on macOS the whole jail turns on it: `/tmp` is a symlink to
239
+ * `/private/tmp`, a `sandbox-exec` rule naming the unresolved path is silently
240
+ * not applied, the access succeeds, and nothing reports an error (D8,
241
+ * measured). That backend is task 4.8, but the resolution belongs here now so
242
+ * it is not a thing 4.8 has to remember.
243
+ *
244
+ * A path that does not exist keeps its lexical form. It cannot be resolved and
245
+ * it will not survive `planJailedSpawn`'s existence filter either.
246
+ */
247
+ export function realpathRequest(request: MountSetRequest): MountSetRequest {
248
+ return {
249
+ ...request,
250
+ modulePath: realpathOrSelf(request.modulePath),
251
+ stateDir: realpathOrSelf(request.stateDir),
252
+ screenshotDir: request.screenshotDir ? realpathOrSelf(request.screenshotDir) : undefined,
253
+ socketDir: realpathOrSelf(request.socketDir),
254
+ runtimePath: realpathOrSelf(request.runtimePath),
255
+ runnerPath: realpathOrSelf(request.runnerPath),
256
+ runtimeModulePaths: request.runtimeModulePaths?.map(realpathOrSelf),
257
+ pathInputs: request.pathInputs.map((input) => ({
258
+ ...input,
259
+ value: realpathOrSelf(input.value),
260
+ })),
261
+ sshDir: request.sshDir ? realpathOrSelf(request.sshDir) : undefined,
262
+ };
263
+ }
264
+
265
+ /**
266
+ * The `node_modules` directories the runner shim resolves its own imports
267
+ * through, nearest first.
268
+ *
269
+ * The shim is not self-contained: it imports `isCompiledHook` from
270
+ * `@celilo/capabilities` and Zod through `hook-protocol.ts`. Node resolution
271
+ * walks up from the importing file looking for `node_modules` at each
272
+ * ancestor, so this walks the same ladder and keeps whichever rungs exist.
273
+ *
274
+ * **It collects only directories literally named `node_modules`, and that is
275
+ * the safety property.** Under an npm install the shim sits at
276
+ * `/var/celilo/node_modules/@celilo/cli/src/hooks/`, so the ancestor holding
277
+ * its dependencies is `/var/celilo` — and binding THAT would put `master.key`
278
+ * and `celilo.db` inside the jail, which is the one outcome the whole change
279
+ * exists to prevent. Appending `node_modules` before testing for existence is
280
+ * what makes the difference, so do not "simplify" this into binding the
281
+ * ancestor itself.
282
+ */
283
+ export function runtimeModulePathsFor(
284
+ runnerPath: string,
285
+ exists: (path: string) => boolean = existsSync,
286
+ ): string[] {
287
+ const found: string[] = [];
288
+ let dir = dirname(runnerPath);
289
+ for (;;) {
290
+ const candidate = join(dir, 'node_modules');
291
+ if (exists(candidate)) {
292
+ found.push(candidate);
293
+ found.push(...linkedPackageDirs(candidate));
294
+ }
295
+ const parent = dirname(dir);
296
+ if (parent === dir) return [...new Set(found)];
297
+ dir = parent;
298
+ }
299
+ }
300
+
301
+ /**
302
+ * Package directories inside `dir` that are reachable ONLY through a symlink.
303
+ *
304
+ * A workspace install — bun's, pnpm's — puts a link in `node_modules` pointing
305
+ * sideways at `packages/<x>`. Binding `node_modules` binds the link and not
306
+ * what it points at, and the shim then dies on
307
+ * `ENOENT reading ".../node_modules/@celilo/capabilities"`. That is the second
308
+ * of the two failures this walk exists to prevent, and like the first it was
309
+ * found by running the jail rather than by reading it.
310
+ *
311
+ * An npm install has real directories here, so this finds nothing and costs
312
+ * one `readdir`. That is why it is written as the general case rather than as
313
+ * a development special case: the two shapes are the same rule, and a
314
+ * dev-only branch here would be a jail nobody tests until production.
315
+ */
316
+ function linkedPackageDirs(dir: string): string[] {
317
+ const linked: string[] = [];
318
+ let entries: Dirent[];
319
+ try {
320
+ entries = readdirSync(dir, { withFileTypes: true });
321
+ } catch {
322
+ return linked;
323
+ }
324
+
325
+ for (const entry of entries) {
326
+ // `.bin` holds links to executables, not to packages. Following them binds
327
+ // single files and buys nothing.
328
+ if (entry.name === '.bin') continue;
329
+ const path = join(dir, entry.name);
330
+ // A scope is a real directory whose MEMBERS are the links.
331
+ if (entry.isDirectory() && entry.name.startsWith('@')) {
332
+ linked.push(...linkedPackageDirs(path));
333
+ continue;
334
+ }
335
+ if (!entry.isSymbolicLink()) continue;
336
+ try {
337
+ const target = realpathSync(path);
338
+ if (statSync(target).isDirectory()) linked.push(target);
339
+ } catch {
340
+ // A dangling link resolves to nothing and binds nothing.
341
+ }
342
+ }
343
+ return linked;
344
+ }
345
+
346
+ function realpathOrSelf(path: string): string {
347
+ try {
348
+ return realpathSync(path);
349
+ } catch {
350
+ return path;
351
+ }
352
+ }
353
+
354
+ /**
355
+ * Decide the command celilo spawns.
356
+ *
357
+ * Planning function (Rule 10.4) — pure, so every branch below is testable
358
+ * without a jail, which matters because the host running the tests usually has
359
+ * no jail at all.
360
+ *
361
+ * @param cmd - The unjailed command: the runtime and the runner shim.
362
+ * @param set - The derived mount set, or `undefined` when the caller has no
363
+ * module tree to jail (the executor's own fixtures, and the bus handler path).
364
+ */
365
+ export function planJailedSpawn(
366
+ cmd: readonly string[],
367
+ set: MountSet | undefined,
368
+ availability: JailAvailability,
369
+ policy: JailPolicy,
370
+ exists: (path: string) => boolean = existsSync,
371
+ ): JailPlan {
372
+ if (policy === 'off') {
373
+ return {
374
+ cmd,
375
+ mode: 'unjailed',
376
+ backend: availability.backend,
377
+ reason: 'CELILO_HOOK_JAIL=off: the operator switched the hook jail off on this host.',
378
+ skipped: [],
379
+ };
380
+ }
381
+
382
+ if (availability.backend === 'none' || !set) {
383
+ const reason = set
384
+ ? availability.reason
385
+ : 'This invocation has no module tree to jail, so there is no mount set to enforce.';
386
+ if (policy === 'required') {
387
+ throw new Error(
388
+ `CELILO_HOOK_JAIL=required and no hook jail is available. ${reason ?? ''}`.trim(),
389
+ );
390
+ }
391
+ return { cmd, mode: 'unjailed', backend: availability.backend, reason, skipped: [] };
392
+ }
393
+
394
+ // A tmpfs needs no source — bubblewrap creates it — so it is never dropped.
395
+ const present = set.entries.filter((e) => e.mode === 'tmpfs' || exists(e.path));
396
+ const skipped = set.entries.filter((e) => !present.includes(e)).map((e) => e.path);
397
+
398
+ return {
399
+ cmd: [
400
+ BWRAP,
401
+ ...JAIL_NAMESPACE_ARGS,
402
+ ...toBwrapArgs({ ...set, entries: present }),
403
+ '--',
404
+ ...cmd,
405
+ ],
406
+ mode: 'jailed',
407
+ backend: availability.backend,
408
+ skipped,
409
+ };
410
+ }
411
+
412
+ export interface JailModeRecord {
413
+ readonly mode: JailMode;
414
+ readonly backend: JailBackend;
415
+ readonly reason?: string;
416
+ /**
417
+ * The host this was measured on.
418
+ *
419
+ * D8's third state — "unjailed, was jailed yesterday" — is the only one that
420
+ * raises an alert, and it is defined per host. Without this the same file
421
+ * copied between boxes, or a database restored onto a new one, reads as a
422
+ * transition that never happened.
423
+ */
424
+ readonly host: string;
425
+ readonly recordedAt: string;
426
+ }
427
+
428
+ /**
429
+ * Where the mode is written. `CELILO_HOOK_JAIL_MODE_PATH` overrides it, the
430
+ * same way `subscriber-store.ts` takes an override for the same reason.
431
+ */
432
+ export function jailModeStorePath(): string {
433
+ return process.env.CELILO_HOOK_JAIL_MODE_PATH ?? join(getDataDir(), JAIL_MODE_FILE);
434
+ }
435
+
436
+ /** The last recorded mode, or `undefined` if nothing has recorded one yet. */
437
+ export function readJailMode(): JailModeRecord | undefined {
438
+ const path = jailModeStorePath();
439
+ if (!existsSync(path)) return undefined;
440
+ try {
441
+ return JSON.parse(readFileSync(path, 'utf-8')) as JailModeRecord;
442
+ } catch {
443
+ // A corrupt file is the same as no file for every consumer: the next run
444
+ // overwrites it. Throwing here would fail a hook over a state write.
445
+ return undefined;
446
+ }
447
+ }
448
+
449
+ /**
450
+ * Write down which mode this run used, and return what it replaced.
451
+ *
452
+ * The previous record is returned rather than acted on. A jailed-to-unjailed
453
+ * transition on the same host is what raises the self-monitor alert, and that
454
+ * monitor is task 4.4 — this is the state it will read.
455
+ *
456
+ * Best effort by design: a read-only data directory must not fail a hook.
457
+ */
458
+ export function recordJailMode(plan: JailPlan): { previous?: JailModeRecord } {
459
+ const previous = readJailMode();
460
+ const record: JailModeRecord = {
461
+ mode: plan.mode,
462
+ backend: plan.backend,
463
+ ...(plan.reason ? { reason: plan.reason } : {}),
464
+ host: hostname(),
465
+ recordedAt: new Date().toISOString(),
466
+ };
467
+
468
+ if (
469
+ previous &&
470
+ previous.host === record.host &&
471
+ previous.mode === record.mode &&
472
+ previous.backend === record.backend
473
+ ) {
474
+ // Unchanged. Rewriting it every hook would churn the file and lose the
475
+ // one timestamp worth having: when the mode last CHANGED.
476
+ return { previous };
477
+ }
478
+
479
+ const path = jailModeStorePath();
480
+ try {
481
+ mkdirSync(dirname(path), { recursive: true });
482
+ const tmp = `${path}.tmp`;
483
+ writeFileSync(tmp, `${JSON.stringify(record, null, 2)}\n`);
484
+ renameSync(tmp, path);
485
+ } catch {
486
+ // Deliberately swallowed, and the only place in this file that is. The
487
+ // mode is diagnostic state; a hook must not fail because celilo could not
488
+ // write it down.
489
+ }
490
+ return { previous };
491
+ }
@@ -84,6 +84,25 @@ export interface MountSetRequest {
84
84
  readonly runtimePath: string;
85
85
  /** The runner shim, which lives in celilo's tree rather than the module's. */
86
86
  readonly runnerPath: string;
87
+ /**
88
+ * The `node_modules` directories the shim resolves its OWN imports through,
89
+ * nearest first. Empty means the shim is self-contained, which it is not.
90
+ *
91
+ * This row exists because the jail was run rather than read. `bwrap` built
92
+ * the namespace correctly, applied every mount below, and the shim then died
93
+ * on `Cannot find module '@celilo/capabilities'` — it imports `isCompiledHook`
94
+ * from there and Zod through `hook-protocol.ts`, and both resolve ABOVE
95
+ * `dirname(runnerPath)`. Under npm that is `/var/celilo/node_modules`; in the
96
+ * repo it is the workspace root's. Neither is inside the shim's directory,
97
+ * so without this the jail cannot start a hook at all, anywhere.
98
+ *
99
+ * The caller walks the filesystem for these (`runtimeModulePathsFor`), which
100
+ * is why they arrive as an argument rather than being computed here. Only
101
+ * directories literally NAMED `node_modules` are ever collected, and that is
102
+ * what keeps `/var/celilo` — `master.key`, `celilo.db` — out of the jail
103
+ * while `/var/celilo/node_modules` goes into it.
104
+ */
105
+ readonly runtimeModulePaths?: readonly string[];
87
106
  /** Contract-declared path inputs, already resolved to values. */
88
107
  readonly pathInputs: readonly DeclaredPathInput[];
89
108
  /**
@@ -154,6 +173,11 @@ export function deriveMountSet(request: MountSetRequest): MountSet {
154
173
  // 2. The runtime. Without it nothing runs, so it is not really a policy row.
155
174
  entries.push(entry(request.runtimePath, 'ro', 'the interpreter'));
156
175
  entries.push(entry(dirname(request.runnerPath), 'ro', 'the runner shim celilo spawns'));
176
+ // See MountSetRequest.runtimeModulePaths. Without these the shim starts and
177
+ // immediately dies on `Cannot find module`.
178
+ for (const dir of request.runtimeModulePaths ?? []) {
179
+ entries.push(entry(resolve(dir), 'ro', "celilo's own dependencies, which the shim imports"));
180
+ }
157
181
  for (const dir of RUNTIME_SUPPORT_DIRS) {
158
182
  entries.push(entry(dir, 'ro', 'shared libraries and trust store'));
159
183
  }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Test fixture: reports what a hook can and cannot reach from inside the jail.
3
+ *
4
+ * Every probe reports an OUTCOME rather than throwing, because the assertion
5
+ * that matters is unreachability and not any particular errno (task 4.9).
6
+ * bubblewrap removes the path and gives `ENOENT`; `sandbox-exec` denies it and
7
+ * gives `EPERM`. Both satisfy the requirement, so this fixture records only
8
+ * whether the access worked and hands the message back for the post-mortem.
9
+ */
10
+
11
+ import { readFileSync, writeFileSync } from 'node:fs';
12
+ import { defineHook } from '@celilo/capabilities';
13
+
14
+ function probe(fn: () => void): { succeeded: boolean; detail: string } {
15
+ try {
16
+ fn();
17
+ return { succeeded: true, detail: 'ok' };
18
+ } catch (error) {
19
+ return { succeeded: false, detail: error instanceof Error ? error.message : String(error) };
20
+ }
21
+ }
22
+
23
+ export default defineHook({
24
+ hook: 'container_created',
25
+ requires: [],
26
+ handler: async (ctx) => {
27
+ const config = ctx.config as {
28
+ planted_secret: string;
29
+ sibling_file: string;
30
+ staged_input: string;
31
+ };
32
+
33
+ return {
34
+ // Outside the module tree entirely, and the whole acceptance criterion:
35
+ // celilo's data directory is not bound, so the key is not merely denied
36
+ // but absent.
37
+ planted_secret: probe(() => {
38
+ readFileSync(config.planted_secret, 'utf-8');
39
+ }),
40
+ // A sibling module's tree. `<store>` itself is never bound, so one `..`
41
+ // reaches nothing.
42
+ sibling_write: probe(() => {
43
+ writeFileSync(config.sibling_file, 'trespassed');
44
+ }),
45
+ // The carve-out: `state/` sits INSIDE the read-only module tree and is
46
+ // bound read-write on top of it.
47
+ state_write: probe(() => {
48
+ writeFileSync(`${ctx.stateDir}/jail-probe`, 'state is writable');
49
+ }),
50
+ // A declared path input under os.tmpdir(), which D9 also makes a fresh
51
+ // tmpfs. If the tmpfs did not lead, this write lands in a private
52
+ // filesystem that vanishes when the hook exits — and the hook SUCCEEDS.
53
+ // The parent asserts the bytes survived, which is the only way to tell.
54
+ staged_write: probe(() => {
55
+ writeFileSync(`${config.staged_input}/produced`, 'staged input survived the tmpfs');
56
+ }),
57
+ };
58
+ },
59
+ });
@@ -0,0 +1,48 @@
1
+ /**
2
+ * The `icon` field's refinement (openspec/changes/module-icons, D3).
3
+ *
4
+ * This is the trust boundary: `manifest.yml` is hand-edited and the value ends
5
+ * up drawn in a coloured row, so the check that it is monochrome-capable lives
6
+ * here rather than in any consumer.
7
+ */
8
+
9
+ import { describe, expect, test } from 'bun:test';
10
+ import { ModuleManifestSchema } from './schema';
11
+
12
+ function manifestWith(icon?: string): Record<string, unknown> {
13
+ return {
14
+ celilo_contract: '1.0',
15
+ id: 'icon-fixture',
16
+ name: 'Icon Fixture',
17
+ version: '1.0.0',
18
+ ...(icon === undefined ? {} : { icon }),
19
+ };
20
+ }
21
+
22
+ describe('manifest icon', () => {
23
+ test('accepts a BMP non-emoji scalar', () => {
24
+ const parsed = ModuleManifestSchema.parse(manifestWith('⛨'));
25
+ expect(parsed.icon).toBe('⛨');
26
+ });
27
+
28
+ test('accepts absence', () => {
29
+ const parsed = ModuleManifestSchema.parse(manifestWith());
30
+ expect(parsed.icon).toBeUndefined();
31
+ });
32
+
33
+ test('rejects U+1F512, naming the monochrome reason rather than the range', () => {
34
+ const result = ModuleManifestSchema.safeParse(manifestWith('🔒'));
35
+ expect(result.success).toBe(false);
36
+ if (result.success) throw new Error('expected the padlock to be rejected');
37
+ const message = result.error.issues[0]?.message ?? '';
38
+ expect(message).toContain('monochrome');
39
+ expect(message).toContain('U+1F512');
40
+ });
41
+
42
+ test('rejects a two-character string', () => {
43
+ const result = ModuleManifestSchema.safeParse(manifestWith('⛨⛨'));
44
+ expect(result.success).toBe(false);
45
+ if (result.success) throw new Error('expected two characters to be rejected');
46
+ expect(result.error.issues[0]?.message ?? '').toContain('exactly one character');
47
+ });
48
+ });