@celilo/cli 1.14.0 → 2.1.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 -2
  2. package/CELILO_SUBSYSTEMS.md +26 -3
  3. package/README.md +0 -2
  4. package/drizzle/0030_drop_module_builds_environment.sql +8 -0
  5. package/drizzle/meta/_journal.json +8 -1
  6. package/package.json +3 -3
  7. package/src/capabilities/public-web-publish.test.ts +18 -0
  8. package/src/cli/commands/alerts-sweep.ts +3 -0
  9. package/src/cli/commands/monitor.ts +15 -2
  10. package/src/cli/commands/system-doctor.test.ts +121 -1
  11. package/src/cli/commands/system-doctor.ts +151 -1
  12. package/src/cli/completion.ts +9 -2
  13. package/src/cli/index.ts +1 -1
  14. package/src/console/control-plane-boundary.test.ts +82 -4
  15. package/src/db/schema.ts +0 -1
  16. package/src/hooks/capability-loader.ts +15 -2
  17. package/src/hooks/executor.ts +116 -17
  18. package/src/hooks/hook-jail-toolchain-reach.test.ts +273 -0
  19. package/src/hooks/hook-jail-unreachability.test.ts +77 -26
  20. package/src/hooks/hook-protocol.ts +44 -0
  21. package/src/hooks/hook-runner-entry.ts +23 -0
  22. package/src/hooks/hook-runner.ts +10 -0
  23. package/src/hooks/hook-trespass.test.ts +74 -12
  24. package/src/hooks/jail-browser-launch-flags.test.ts +34 -0
  25. package/src/hooks/jail.test.ts +92 -0
  26. package/src/hooks/jail.ts +304 -32
  27. package/src/hooks/mount-set.test.ts +116 -7
  28. package/src/hooks/mount-set.ts +189 -20
  29. package/src/hooks/remote-broker.test.ts +350 -0
  30. package/src/hooks/remote-broker.ts +404 -0
  31. package/src/hooks/run-named-hook.ts +2 -0
  32. package/src/hooks/test-fixtures/jail-probe-hook.ts +14 -1
  33. package/src/hooks/test-fixtures/jail-toolchain-hook.ts +227 -0
  34. package/src/hooks/test-fixtures/remote-bridge-probe.ts +82 -0
  35. package/src/hooks/unjailed-lint.test.ts +254 -0
  36. package/src/hooks/unjailed-lint.ts +395 -0
  37. package/src/policy/module-business-baseline.ts +13 -1
  38. package/src/policy/module-script-scan.ts +60 -1
  39. package/src/policy/no-hand-built-ssh.test.ts +39 -1
  40. package/src/policy/no-module-business-in-core.test.ts +1 -1
  41. package/src/services/alerting/coverage-source.test.ts +86 -0
  42. package/src/services/alerting/coverage-source.ts +11 -1
  43. package/src/services/alerting/hook-jail.test.ts +66 -0
  44. package/src/services/alerting/hook-jail.ts +70 -0
  45. package/src/services/alerting/run-monitor.test.ts +62 -0
  46. package/src/services/alerting/run-monitor.ts +12 -0
  47. package/src/services/alerting/sweep-runner.test.ts +1 -0
  48. package/src/services/backup-create.ts +3 -0
  49. package/src/services/backup-restore.ts +2 -0
  50. package/src/services/control-plane-bootstrap.test.ts +177 -0
  51. package/src/services/control-plane-bootstrap.ts +176 -0
  52. package/src/services/control-plane-health.test.ts +66 -0
  53. package/src/services/control-plane-health.ts +67 -0
  54. package/src/services/deploy-ansible.ts +9 -1
  55. package/src/services/deployed-systems.ts +12 -0
  56. package/src/services/dns-discovery.test.ts +93 -0
  57. package/src/services/dns-discovery.ts +92 -0
  58. package/src/services/fleet-checks.ts +6 -2
  59. package/src/services/health-runner.ts +36 -3
  60. package/src/services/module-build.test.ts +1 -64
  61. package/src/services/module-build.ts +10 -86
  62. package/src/services/module-deploy.ts +71 -0
  63. package/src/services/remote-access.test.ts +223 -0
  64. package/src/services/remote-access.ts +149 -0
  65. package/src/services/restore-from-file.ts +6 -1
  66. package/src/services/static-content-converge.test.ts +338 -0
  67. package/src/services/static-content-converge.ts +299 -0
@@ -0,0 +1,273 @@
1
+ /**
2
+ * What TOOLING the jail leaves a hook (task 4.13).
3
+ *
4
+ * `hook-jail-unreachability.test.ts` asserts D9's filesystem claims and they
5
+ * hold. This suite asks the question D9's table never answers, and the answer
6
+ * turns out to be the reason task 4.13 exists: **the fleet's real hooks shell
7
+ * out, and the jail as derived today has no shell.**
8
+ *
9
+ * `execSync` spawns `/bin/sh -c`. `remote.ts` — the single seam every remote
10
+ * primitive routes through — builds an `ssh <user>@<host> <cmd>` string and
11
+ * hands it to exactly that. The derivation binds `/usr/lib`, `/lib`, `/lib64`
12
+ * and `/etc/ssl`, and nothing else outside the module's own tree. No `/bin`,
13
+ * no `/usr/bin`, no `/etc/resolv.conf`.
14
+ *
15
+ * That is measured here rather than argued, because reading the derivation is
16
+ * what produced the belief that stage 2 was complete. The mount set is correct
17
+ * about every path it names. It is silent about the ones a hook needs to run
18
+ * another program at all, and silence and correctness look identical from the
19
+ * outside.
20
+ *
21
+ * ⚠️ **These tests assert the CURRENT reach, including the failures.** They are
22
+ * not a wish list. When stage 2 gains a shell — or stage 3 brokers the remote
23
+ * calls and hooks stop needing one — the expectations here change with it, and
24
+ * the diff that changes them is where somebody states which of those happened.
25
+ * A suite that asserted the desired end state would be red for the whole of
26
+ * stage 2 and would teach nobody anything.
27
+ *
28
+ * **Needs a real jail and skips without one, loudly**, for the reason its
29
+ * neighbour gives: a check that cannot reach its subject returns a confident
30
+ * answer about nothing.
31
+ */
32
+
33
+ import { describe, expect, test } from 'bun:test';
34
+ import { existsSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs';
35
+ import { tmpdir } from 'node:os';
36
+ import { dirname, join, resolve } from 'node:path';
37
+ import { executeHookScript } from './executor';
38
+ import { detectJailBackend } from './jail';
39
+ import { createCapturingLogger } from './logger';
40
+ import type { HookContext } from './types';
41
+
42
+ const PROBE_HOOK = resolve(__dirname, 'test-fixtures/jail-toolchain-hook.ts');
43
+
44
+ interface Probe {
45
+ succeeded: boolean;
46
+ detail: string;
47
+ }
48
+
49
+ interface Reach {
50
+ shell: Probe;
51
+ exec_without_shell: Probe;
52
+ ssh: Probe;
53
+ ansible: Probe;
54
+ resolv_conf: Probe;
55
+ dns_lookup: Probe;
56
+ nsswitch: Probe;
57
+ hosts_file: Probe;
58
+ playwright_cache: Probe;
59
+ celilo_browser_root: Probe;
60
+ data_dir_sibling: Probe;
61
+ dynamic_loader: Probe;
62
+ browser_binary_present: Probe;
63
+ browser_sandboxed: Probe;
64
+ browser_no_sandbox: Probe;
65
+ bwrap_present: Probe;
66
+ }
67
+
68
+ /**
69
+ * A real browser to launch inside the jail, or nothing.
70
+ *
71
+ * Task 4.10's question cannot be answered without one, and most hosts have
72
+ * none. `CELILO_PROBE_BROWSER` names it for the run that does — the Docker
73
+ * image this suite's finding was measured in. Absent, the two browser probes
74
+ * report "no browser supplied" and the run says so out loud rather than
75
+ * reporting an ABSENT that means "not tested".
76
+ */
77
+ const PROBE_BROWSER = process.env.CELILO_PROBE_BROWSER ?? '';
78
+
79
+ /**
80
+ * The browser's directory, refused if handing it over would defeat this
81
+ * suite's own `bwrap` guard.
82
+ *
83
+ * Binding the browser's directory as a path input is how a browser reaches the
84
+ * jail here. Point `CELILO_PROBE_BROWSER` at `/usr/bin/chromium` and that
85
+ * directory is `/usr/bin`, which carries `bwrap` in with it — the guard below
86
+ * then fails for a reason that is the harness's fault, not the derivation's.
87
+ * Measured: it happened on the first run of this file.
88
+ *
89
+ * Debian's `/usr/bin/chromium` is a `#!/bin/sh` wrapper anyway and cannot exec
90
+ * in a jail with no shell. The real ELF is under `/usr/lib`, which the
91
+ * derivation already binds, so the honest browser to name is that one.
92
+ */
93
+ function probeBrowserDir(): string {
94
+ const dir = dirname(PROBE_BROWSER);
95
+ if (existsSync(join(dir, 'bwrap'))) {
96
+ throw new Error(
97
+ `CELILO_PROBE_BROWSER=${PROBE_BROWSER} would bind ${dir}, which holds bwrap and would defeat the guard this suite exists to keep. Name the browser's real ELF (Debian: /usr/lib/chromium/chromium), not the /usr/bin wrapper.`,
98
+ );
99
+ }
100
+ return dir;
101
+ }
102
+
103
+ const availability = detectJailBackend();
104
+ const jailed = availability.backend !== 'none';
105
+
106
+ /** Run the probe hook against a module tree laid out the way celilo lays one out. */
107
+ async function measureReach(): Promise<{ reach: Reach; cleanup: () => void }> {
108
+ const scratch = mkdtempSync(join(tmpdir(), 'celilo-jail-reach-'));
109
+ const modulePath = join(scratch, 'modules', 'jail-reach');
110
+ const stateDir = join(modulePath, 'state');
111
+ const screenshotDir = join(modulePath, 'screenshots', 'run');
112
+ mkdirSync(join(modulePath, 'scripts'), { recursive: true });
113
+ mkdirSync(stateDir, { recursive: true });
114
+ mkdirSync(screenshotDir, { recursive: true });
115
+
116
+ const { logger } = createCapturingLogger();
117
+ const context: HookContext = {
118
+ config: PROBE_BROWSER ? { browser_executable: PROBE_BROWSER } : {},
119
+ secrets: {},
120
+ systems: [],
121
+ logger,
122
+ debug: false,
123
+ screenshotDir,
124
+ stateDir,
125
+ capabilities: {},
126
+ };
127
+
128
+ // The jailed run jails under `required`: ce-29z made `auto` defer on
129
+ // sandbox-exec until D14 exists, so this suite's jail is the operator's
130
+ // explicit act — the bypass the deferral deliberately leaves open.
131
+ const savedPolicy = process.env.CELILO_HOOK_JAIL;
132
+ process.env.CELILO_HOOK_JAIL = 'required';
133
+ try {
134
+ const reach = (await executeHookScript(PROBE_HOOK, context, {
135
+ timeoutMs: 60_000,
136
+ idleTimeoutMs: 60_000,
137
+ jail: {
138
+ modulePath,
139
+ // The browser reaches the jail as a DECLARED PATH INPUT, which is the
140
+ // mechanism 4.10's fix would use. Binding it by editing the derivation
141
+ // and then asking the derivation whether it is bound would prove
142
+ // nothing.
143
+ pathInputs: PROBE_BROWSER
144
+ ? [{ name: 'browser_executable', value: probeBrowserDir(), access: 'read' as const }]
145
+ : [],
146
+ },
147
+ })) as unknown as Reach;
148
+
149
+ return { reach, cleanup: restorePolicy };
150
+ } catch (error) {
151
+ restorePolicy();
152
+ throw error;
153
+ }
154
+
155
+ function restorePolicy() {
156
+ if (savedPolicy === undefined) delete process.env.CELILO_HOOK_JAIL;
157
+ else process.env.CELILO_HOOK_JAIL = savedPolicy;
158
+ rmSync(scratch, { recursive: true, force: true });
159
+ }
160
+ }
161
+
162
+ describe.skipIf(!jailed)(`what a jailed hook can run (backend: ${availability.backend})`, () => {
163
+ // 30s, not bun's default 5s: the DNS probe deliberately waits for an answer
164
+ // that never comes, and a runner timeout would report as a suite failure
165
+ // rather than as the measurement it is.
166
+ test('measures the reach', async () => {
167
+ const { reach, cleanup } = await measureReach();
168
+ try {
169
+ // Printed unconditionally, and to stderr because bun swallows a passing
170
+ // test's stdout. This suite's value is the table, and a run that only
171
+ // says "1 pass" has thrown the finding away.
172
+ for (const [name, probe] of Object.entries(reach)) {
173
+ console.error(` ${probe.succeeded ? 'REACHED' : 'ABSENT '} ${name}: ${probe.detail}`);
174
+ }
175
+
176
+ // ── The finding, per backend ────────────────────────────────────────
177
+ // bubblewrap BUILDS a namespace, so reach is filesystem absence: no
178
+ // `/bin` is bound, so no shell, so `execSync` cannot run at all. 22
179
+ // module script files import `node:child_process`.
180
+ //
181
+ // `sandbox-exec` FILTERS the tree that is already there, and its parity
182
+ // statements change what exec means. `(allow process*)` lets the kernel
183
+ // map and run an image the jail cannot READ — exec is not file-read in
184
+ // SBPL — so `/bin/sh` runs, `/bin/echo` runs, and `ssh` spawns. Measured
185
+ // 2026-08-30, first run of this suite under the second backend. The
186
+ // boundary that DOES hold on macOS is the filesystem and the credential:
187
+ // writes outside the mount set are EPERM, secrets are unreadable, and
188
+ // the `~/.ssh` key stage 3 withheld is not in the jail for ssh to use —
189
+ // asserted by hook-trespass.test.ts and hook-jail-unreachability.test.ts,
190
+ // which pass under both backends.
191
+ if (availability.backend === 'sandbox-exec') {
192
+ expect(reach.shell.succeeded).toBe(true);
193
+ expect(reach.exec_without_shell.succeeded).toBe(true);
194
+ // It spawns; it cannot authenticate. That is stage 3's boundary, not
195
+ // this suite's.
196
+ expect(reach.ssh.succeeded).toBe(true);
197
+ // `ansible-playbook` is not installed on the probe host, so the row
198
+ // measures the HOST, not the jail: posix_spawn of anything present is
199
+ // allowed. No assertion either way.
200
+ // `dns_lookup` also stays a printed row rather than an assertion:
201
+ // measured REACHED — `(allow network*)` plus macOS resolving
202
+ // out-of-process in mDNSResponder — but gating on it needs a live
203
+ // resolver, which a unit suite should not require.
204
+ } else {
205
+ expect(reach.shell.succeeded).toBe(false);
206
+ expect(reach.exec_without_shell.succeeded).toBe(false);
207
+
208
+ // `~/.ssh` is bound read-only in stage 2 specifically so `remote.ts`
209
+ // keeps working. There is no `ssh` to hand that key to.
210
+ expect(reach.ssh.succeeded).toBe(false);
211
+ expect(reach.ansible.succeeded).toBe(false);
212
+ }
213
+
214
+ // Nothing binds the resolver's configuration, on either platform:
215
+ // bubblewrap by absence, sandbox-exec by `deny default` (EPERM).
216
+ expect(reach.resolv_conf.succeeded).toBe(false);
217
+
218
+ // ── Task 4.10 ──────────────────────────────────────────────────────
219
+ // `~/.cache/ms-playwright` is the path task 4.10 names and it is bound
220
+ // by nothing, deliberately: `managed-browser-runtime` moved the browser
221
+ // into a celilo-owned tree, so binding the cache would bind a directory
222
+ // nothing launches from.
223
+ expect(reach.playwright_cache.succeeded).toBe(false);
224
+
225
+ // Binding BROWSER_ROOT must not expose what sits beside it. This one IS
226
+ // asserted unconditionally: `master.key` is unreadable whether or not
227
+ // the browser directory exists on this host, so unlike the row below
228
+ // there is no configuration in which this passes vacuously.
229
+ expect(reach.data_dir_sibling.succeeded).toBe(false);
230
+
231
+ // `BROWSER_ROOT` is NOT asserted here, and the reason is worth stating
232
+ // because asserting it would be the trap this file exists to avoid.
233
+ // `deriveMountSet` now emits the row, but `planJailedSpawn` drops any
234
+ // row whose source is missing — so on a host with no provisioned
235
+ // browser this probe reports ABSENT for a reason that has nothing to do
236
+ // with the mount set, and an assertion either way would be measuring
237
+ // the host rather than the derivation. The row itself is asserted
238
+ // hermetically in `mount-set.test.ts`, where it can be.
239
+
240
+ // The nesting question, asserted only on a run that supplied a browser.
241
+ // Measured 2026-08-28 in `oven/bun:latest` on aarch64 with Debian's
242
+ // chromium: with its own sandbox Chromium will not start inside the
243
+ // jail, and `--no-sandbox` renders. That is task 4.10's prediction
244
+ // confirmed, and it says what the fix has to include.
245
+ if (PROBE_BROWSER) {
246
+ expect(reach.browser_binary_present.succeeded).toBe(true);
247
+ expect(reach.browser_sandboxed.succeeded).toBe(false);
248
+ expect(reach.browser_no_sandbox.succeeded).toBe(true);
249
+ }
250
+
251
+ // ── The guard ──────────────────────────────────────────────────────
252
+ // Opposite direction, and it must never flip. D9 predicts the exact
253
+ // edit that would flip it: someone reads the failures above, binds
254
+ // `/usr/bin` to fix them, and brings `bwrap` in with it. `isForbidden`
255
+ // compares whole paths, so a bind of the DIRECTORY passes that filter.
256
+ // Asserted under bubblewrap only: there is no bubblewrap jail on macOS,
257
+ // so the row would measure whether the OPERATOR has bwrap installed,
258
+ // and posix_spawn of it is allowed anyway.
259
+ if (availability.backend !== 'sandbox-exec') {
260
+ expect(reach.bwrap_present.succeeded).toBe(false);
261
+ }
262
+ } finally {
263
+ cleanup();
264
+ }
265
+ }, 30_000);
266
+ });
267
+
268
+ describe.skipIf(jailed)('no jail on this host', () => {
269
+ test('says so rather than passing quietly', () => {
270
+ console.log(`hook jail unavailable, so 4.13's reach was NOT measured: ${availability.reason}`);
271
+ expect(availability.backend).toBe('none');
272
+ });
273
+ });
@@ -27,10 +27,13 @@
27
27
 
28
28
  import { describe, expect, test } from 'bun:test';
29
29
  import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
30
- import { tmpdir } from 'node:os';
30
+ import { homedir, tmpdir } from 'node:os';
31
31
  import { join, resolve } from 'node:path';
32
+
33
+ /** Uniquely named so it never collides with a real key on a jailed dev box. */
34
+ const PLANTED_KEY_NAME = 'id_celilo_jail_probe';
32
35
  import { executeHookScript } from './executor';
33
- import { detectJailBackend } from './jail';
36
+ import { detectJailBackend, jailPolicy } from './jail';
34
37
  import { createCapturingLogger } from './logger';
35
38
  import type { HookContext } from './types';
36
39
 
@@ -42,7 +45,12 @@ interface Probe {
42
45
  }
43
46
 
44
47
  const availability = detectJailBackend();
45
- const jailed = availability.backend !== 'none';
48
+ // A host with a backend is not a host that JAILS: `CELILO_HOOK_JAIL=off` is an
49
+ // operator switch and the executor honours it, so without this the suite runs
50
+ // its assertions against a deliberately unjailed hook and reports the jail
51
+ // broken. Only reachable since macOS gained a backend (task 4.8) — before that
52
+ // every Mac skipped for want of one and the hole never showed.
53
+ const jailed = availability.backend !== 'none' && jailPolicy() !== 'off';
46
54
 
47
55
  interface Rig {
48
56
  outputs: {
@@ -50,6 +58,7 @@ interface Rig {
50
58
  sibling_write: Probe;
51
59
  state_write: Probe;
52
60
  staged_write: Probe;
61
+ ssh_key: Probe;
53
62
  };
54
63
  stagedInput: string;
55
64
  siblingFile: string;
@@ -85,6 +94,20 @@ async function runProbe(): Promise<Rig> {
85
94
  // produces. This is the path the tmpfs would erase.
86
95
  const stagedInput = mkdtempSync(join(tmpdir(), 'celilo-jail-staged-'));
87
96
 
97
+ // A throwaway SSH key at the exact path the pre-stage-3 jail bound —
98
+ // `homedir()/.ssh` — so the flip (task 5.5) is real: the red baseline
99
+ // binds this directory and the probe reads the key, this branch binds no
100
+ // `.ssh` at all and the read gives ENOENT. Guarded to jailed hosts only
101
+ // (this whole suite is `skipIf(!jailed)`), which on a Mac is never — so
102
+ // the operator's real key is never touched on a dev box; on a jailed CI
103
+ // box homedir is an ephemeral container root. Uniquely named and removed
104
+ // in cleanup regardless.
105
+ const sshDir = join(homedir(), '.ssh');
106
+ const plantedKey = join(sshDir, PLANTED_KEY_NAME);
107
+ const createdSshDir = !existsSync(sshDir);
108
+ mkdirSync(sshDir, { recursive: true });
109
+ writeFileSync(plantedKey, 'not-a-real-key\n');
110
+
88
111
  const { logger } = createCapturingLogger();
89
112
  const context: HookContext = {
90
113
  config: {
@@ -101,31 +124,45 @@ async function runProbe(): Promise<Rig> {
101
124
  capabilities: {},
102
125
  };
103
126
 
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
- };
127
+ // The jailed run jails under `required`: ce-29z made `auto` defer on
128
+ // sandbox-exec until D14 exists, so this suite's jail is the operator's
129
+ // explicit act — the bypass the deferral deliberately leaves open.
130
+ const savedPolicy = process.env.CELILO_HOOK_JAIL;
131
+ process.env.CELILO_HOOK_JAIL = 'required';
132
+ try {
133
+ const outputs = (await executeHookScript(PROBE_HOOK, context, {
134
+ timeoutMs: 60_000,
135
+ idleTimeoutMs: 60_000,
136
+ jail: {
137
+ modulePath,
138
+ pathInputs: [{ name: 'staged_input', value: stagedInput, access: 'write' }],
139
+ },
140
+ })) as unknown as Rig['outputs'];
141
+ return {
142
+ outputs,
143
+ stagedInput,
144
+ siblingFile,
145
+ cleanup: () => {
146
+ if (savedPolicy === undefined) delete process.env.CELILO_HOOK_JAIL;
147
+ else process.env.CELILO_HOOK_JAIL = savedPolicy;
148
+ rmSync(scratch, { recursive: true, force: true });
149
+ rmSync(stagedInput, { recursive: true, force: true });
150
+ rmSync(plantedKey, { force: true });
151
+ if (createdSshDir) rmSync(sshDir, { recursive: true, force: true });
152
+ },
153
+ };
154
+ } catch (error) {
155
+ if (savedPolicy === undefined) delete process.env.CELILO_HOOK_JAIL;
156
+ else process.env.CELILO_HOOK_JAIL = savedPolicy;
157
+ throw error;
158
+ }
122
159
  }
123
160
 
124
161
  describe.skipIf(!jailed)(`the jail is real (backend: ${availability.backend})`, () => {
125
162
  test('a hook cannot reach celilo’s master key, or a sibling module', async () => {
126
163
  const rig = await runProbe();
127
164
  try {
128
- const { planted_secret, sibling_write, state_write, staged_write } = rig.outputs;
165
+ const { planted_secret, sibling_write, state_write, staged_write, ssh_key } = rig.outputs;
129
166
  console.log(['', 'jail probe:', JSON.stringify(rig.outputs, null, 2)].join('\n'));
130
167
 
131
168
  // Unreachability, not an errno. Under bubblewrap the message is ENOENT
@@ -136,6 +173,12 @@ describe.skipIf(!jailed)(`the jail is real (backend: ${availability.backend})`,
136
173
  // And the parent's own view: the write did not land by another route.
137
174
  expect(existsSync(rig.siblingFile)).toBe(false);
138
175
 
176
+ // Stage 3 (design D12, task 5.5): the SSH credential is not bound, so
177
+ // a jailed hook cannot authenticate anywhere by hand. This is the
178
+ // recurrence-gate row that FLIPPED when the `~/.ssh` mount was removed
179
+ // — it read `succeeded: true` for the whole of stage 2.
180
+ expect(ssh_key.succeeded).toBe(false);
181
+
139
182
  // The carve-out. `state/` is inside the read-only module tree and is
140
183
  // bound read-write on top of it; if the ordering were wrong this is the
141
184
  // assertion that would catch it.
@@ -161,13 +204,21 @@ describe.skipIf(!jailed)(`the jail is real (backend: ${availability.backend})`,
161
204
  }, 90_000);
162
205
  });
163
206
 
164
- describe.skipIf(jailed)('no jail on this host', () => {
207
+ describe.skipIf(jailed)('this run is not jailed', () => {
165
208
  test('says so, rather than reporting a pass it did not earn', () => {
166
209
  // 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".
210
+ // run on an unjailed host reading as "the jail was proven".
211
+ const why =
212
+ availability.backend === 'none'
213
+ ? (availability.reason ?? 'no backend')
214
+ : `CELILO_HOOK_JAIL=${process.env.CELILO_HOOK_JAIL}, so the operator switched the ${availability.backend} jail off here`;
168
215
  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).`,
216
+ `\nhook jail: SKIPPED the live suite — ${why}\nThese properties are proven wherever a backend is available and enabled. The hermetic half runs everywhere (jail.test.ts).`,
170
217
  );
171
- expect(availability.backend).toBe('none');
218
+ // Both reasons are legitimate and they are different facts, so assert the
219
+ // disjunction rather than one of them. Asserting `backend === 'none'` alone
220
+ // went red the day macOS got a backend AND the operator turned it off — a
221
+ // combination that is not a defect in anything.
222
+ expect(availability.backend === 'none' || jailPolicy() === 'off').toBe(true);
172
223
  });
173
224
  });
@@ -31,6 +31,28 @@ export const HOOK_PROTOCOL_VERSION = 1;
31
31
  export const HOOK_SOCKET_ENV = 'CELILO_HOOK_SOCKET';
32
32
  /** Environment variable carrying the parent's protocol version to the child. */
33
33
  export const HOOK_PROTOCOL_VERSION_ENV = 'CELILO_HOOK_PROTOCOL_VERSION';
34
+ /**
35
+ * Environment variable carrying the remote-ops broker's socket path (stage 3,
36
+ * design D12). The ASKING half lives in `@celilo/capabilities`' remote
37
+ * primitives, which a module bundles — so that side names this variable as a
38
+ * string literal of its own (`packages/capabilities/src/remote.ts`), and the
39
+ * two must agree the way the socket framing must.
40
+ */
41
+ export const HOOK_REMOTE_SOCKET_ENV = 'CELILO_HOOK_REMOTE_SOCKET';
42
+ /**
43
+ * Environment variable carrying the derived mount set to the child, as JSON
44
+ * (task 4.7, the unjailed advisory lint).
45
+ *
46
+ * Set ONLY when the run is unjailed AND a mount set exists — its presence is
47
+ * the shim's signal to install the lint, so a jailed run carries nothing. The
48
+ * environment is the right channel rather than a protocol frame because this
49
+ * is spawn-time configuration of the shim, exactly like the two socket paths
50
+ * above, and because the value is finalised in the same planning step that
51
+ * picks the spawn command. The child validates it with `MountSetWireSchema`
52
+ * before use; it is written by celilo and read by celilo, but it crosses a
53
+ * process boundary and gets the same treatment as any other payload.
54
+ */
55
+ export const HOOK_MOUNT_SET_ENV = 'CELILO_HOOK_MOUNT_SET';
34
56
 
35
57
  /**
36
58
  * An error crossing the boundary.
@@ -111,6 +133,28 @@ export const ContextFrameSchema = z.object({
111
133
  context: z.record(z.unknown()),
112
134
  });
113
135
 
136
+ /**
137
+ * One row of the mount set, on the wire.
138
+ *
139
+ * Structural twin of `mount-set.ts`'s `MountEntry`. Duplicated as a schema
140
+ * rather than imported because this file is the wire contract and the wire
141
+ * must not grow a compile-time dependency on the derivation's internals —
142
+ * `hook-protocol.ts`'s docblock records who may import whom.
143
+ */
144
+ export const MountEntrySchema = z.object({
145
+ path: z.string(),
146
+ mode: z.enum(['ro', 'rw', 'tmpfs']),
147
+ reason: z.string(),
148
+ });
149
+
150
+ /** The derived mount set, on the wire. Structural twin of `MountSet`. */
151
+ export const MountSetWireSchema = z.object({
152
+ entries: z.array(MountEntrySchema),
153
+ chdir: z.string(),
154
+ });
155
+
156
+ export type MountSetWire = z.infer<typeof MountSetWireSchema>;
157
+
114
158
  /**
115
159
  * The capability shape descriptor (design D2).
116
160
  *
@@ -0,0 +1,23 @@
1
+ /**
2
+ * The runner shim's entry point — the first file the hook process executes.
3
+ *
4
+ * **It exists to fix the evaluation order of one import.** The advisory lint
5
+ * (`unjailed-lint.ts`) must have its `node:fs` wrappers in place before any
6
+ * other module in this process ESM-loads `node:fs`, because Bun resolves a
7
+ * builtin's named ESM exports once — the first import freezes them. The
8
+ * runner shim itself imports `@celilo/capabilities`, whose graph reaches
9
+ * `node:fs`, so by the time the shim's own body could install anything, the
10
+ * facade is already frozen and the lint is silently disarmed (measured while
11
+ * landing task 4.7; `unjailed-lint.test.ts` goes red without this file).
12
+ *
13
+ * So: install first (the module also self-installs at evaluation; the guard
14
+ * makes whichever lands first correct), and only then hand off to the real
15
+ * runner. Nothing else belongs in this file, and nothing here may import a
16
+ * module whose graph reaches `node:fs` — that is the whole point of it.
17
+ */
18
+
19
+ import { installUnjailedLintIfUnjailed } from './unjailed-lint';
20
+
21
+ installUnjailedLintIfUnjailed();
22
+
23
+ await import('./hook-runner');
@@ -27,6 +27,12 @@ import {
27
27
  versionMismatch,
28
28
  } from './hook-protocol';
29
29
  import type { HookContext, HookLogger } from './types';
30
+ import { forwardLintWarnings } from './unjailed-lint';
31
+
32
+ // The advisory lint was installed before this module was even loaded — the
33
+ // executor spawns `hook-runner-entry.ts`, whose first statement forces
34
+ // `unjailed-lint`'s evaluation (see that module's docblock for why the order
35
+ // is load-bearing there and irrelevant here).
30
36
 
31
37
  const socketPath = process.env[HOOK_SOCKET_ENV];
32
38
  if (!socketPath) {
@@ -123,6 +129,10 @@ async function runHook(): Promise<void> {
123
129
  if (!(field in contextData)) throw new Error(`Hook context is missing '${field}'.`);
124
130
  }
125
131
 
132
+ // Before the import, not after — the lint's warnings flow through the
133
+ // hook's logger from here on (anything earlier was buffered).
134
+ forwardLintWarnings(logger.warn);
135
+
126
136
  const context = {
127
137
  ...contextData,
128
138
  logger,