@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
@@ -17,7 +17,7 @@
17
17
  */
18
18
 
19
19
  import { dirname, isAbsolute, join, resolve } from 'node:path';
20
- import type { PathAccess } from '@celilo/capabilities';
20
+ import { BROWSER_ROOT, type PathAccess } from '@celilo/capabilities';
21
21
 
22
22
  /**
23
23
  * `tmpfs` is not an access level, it is "put a fresh empty filesystem here".
@@ -105,20 +105,34 @@ export interface MountSetRequest {
105
105
  readonly runtimeModulePaths?: readonly string[];
106
106
  /** Contract-declared path inputs, already resolved to values. */
107
107
  readonly pathInputs: readonly DeclaredPathInput[];
108
- /**
109
- * The operator's `~/.ssh`, read-only, STAGE 2 ONLY.
110
- *
111
- * `remote.ts` still runs inside the hook and needs the key. Stage 3 brokers
112
- * those calls and drops this row, which is what turns D12's target check
113
- * from a convention into a boundary. Dropping it before stage 3 lands
114
- * hardens nothing — it just stops every hook reaching its own systems.
115
- */
116
- readonly sshDir?: string;
117
108
  }
118
109
 
119
110
  /** Directories whose contents the runtime needs in order to start at all. */
120
111
  const RUNTIME_SUPPORT_DIRS = ['/usr/lib', '/lib', '/lib64', '/etc/ssl'] as const;
121
112
 
113
+ /**
114
+ * What resolving a hostname needs. Read-only, and absent ones are dropped.
115
+ *
116
+ * Without these a jailed hook cannot resolve a NAME. `getaddrinfo` finds no
117
+ * nameserver, falls back to a loopback that answers nothing, and the call dies
118
+ * as `ETIMEOUT` — which reads as the remote endpoint being down rather than as
119
+ * the jail having no resolver. Measured on `namecheap`'s `validate_config`:
120
+ * `getaddrinfo ETIMEOUT dynamicdns.park-your-domain.com`, against an endpoint
121
+ * that was up and one the e2e topology answers for.
122
+ *
123
+ * This is not a widening of what a hook may reach. Design D12 already records
124
+ * as a residual that "a hook can still `fetch()` any HTTP endpoint directly;
125
+ * only `probeHttp` consults the target check" — so the network is already
126
+ * open, and a hook could always dial a literal IP. Withholding the resolver
127
+ * config did not close that door; it only made the door work for addresses and
128
+ * not for names, which is an accident rather than a policy.
129
+ *
130
+ * It sits beside `/etc/ssl` for the same reason that does: an outbound call
131
+ * needs a trust store AND a way to turn a name into an address, and binding
132
+ * one without the other leaves half a capability.
133
+ */
134
+ const RESOLVER_FILES = ['/etc/resolv.conf', '/etc/nsswitch.conf', '/etc/hosts'] as const;
135
+
122
136
  /**
123
137
  * Paths that must NEVER appear in a mount set, whatever asks for them.
124
138
  *
@@ -181,6 +195,37 @@ export function deriveMountSet(request: MountSetRequest): MountSet {
181
195
  for (const dir of RUNTIME_SUPPORT_DIRS) {
182
196
  entries.push(entry(dir, 'ro', 'shared libraries and trust store'));
183
197
  }
198
+ for (const file of RESOLVER_FILES) {
199
+ entries.push(entry(file, 'ro', 'name resolution — see RESOLVER_FILES'));
200
+ }
201
+ // The fleet browser, read-only (task 4.10).
202
+ //
203
+ // `BROWSER_ROOT` rather than `~/.cache/ms-playwright`, which is what task 4.10
204
+ // says to bind and is now the wrong path: `managed-browser-runtime` moved the
205
+ // browser into a celilo-owned tree and `resolveBrowser()` is what a hook asks
206
+ // for it. Binding the cache would bind a directory nothing launches from.
207
+ //
208
+ // Read-only because a hook has no business writing to the shared browser
209
+ // install, and unconditional because it costs nothing when absent —
210
+ // `planJailedSpawn` drops a row whose source does not exist, so a host with no
211
+ // provisioned browser gets no row and no error. Gating it on some
212
+ // "this module uses a browser" signal would need a declaration that does not
213
+ // exist, and would fail closed in the one case that matters.
214
+ //
215
+ // Note what this does NOT bind: `/var/lib/celilo` itself stays out, so the
216
+ // data directory beside it is as absent as it was before. This is one
217
+ // subdirectory, named explicitly.
218
+ //
219
+ // ⚠️ The bind is necessary and not sufficient. Measured 2026-08-28 under a
220
+ // real bubblewrap jail (`hook-jail-toolchain-reach.test.ts`): Chromium with
221
+ // its own sandbox does NOT start inside the jail, and the same binary with
222
+ // `--no-sandbox` renders. Chromium's sandbox forks a helper into a new user
223
+ // namespace and nesting that inside bubblewrap's unprivileged one fails. So a
224
+ // browser hook also needs `--no-sandbox`, which belongs to whatever launches
225
+ // the browser rather than here. That interim landed 2026-08-31 (celilo#1215):
226
+ // the launch path in `test-fixtures/jail-toolchain-hook.ts` carries the flag,
227
+ // and `jail-browser-launch-flags.test.ts` pins it there.
228
+ entries.push(entry(BROWSER_ROOT, 'ro', 'the fleet browser, when one is provisioned'));
184
229
 
185
230
  // 3. The module's own tree, read-only, then its writable directories carved
186
231
  // on top. bubblewrap resolves that in the right order, which is why the
@@ -215,16 +260,11 @@ export function deriveMountSet(request: MountSetRequest): MountSet {
215
260
  );
216
261
  }
217
262
 
218
- // 6. Stage 2 only. See MountSetRequest.sshDir.
219
- if (request.sshDir) {
220
- entries.push(
221
- entry(
222
- resolve(request.sshDir),
223
- 'ro',
224
- 'remote.ts needs the key until the broker holds it (D12)',
225
- ),
226
- );
227
- }
263
+ // There is deliberately NO `~/.ssh` row (stage 3, design D12). With no key
264
+ // in the jail, a hand-built `ssh` cannot authenticate anywhere; the remote
265
+ // primitives cross to the remote-ops broker instead, and withholding the
266
+ // key is what makes that broker's target check a boundary rather than a
267
+ // convention.
228
268
 
229
269
  return {
230
270
  entries: entries.filter((e) => !isForbidden(e.path)),
@@ -256,3 +296,132 @@ export function toBwrapArgs(set: MountSet): string[] {
256
296
  args.push('--chdir', set.chdir);
257
297
  return args;
258
298
  }
299
+
300
+ /**
301
+ * Render a mount set as a `sandbox-exec` profile, in order (task 4.8).
302
+ *
303
+ * The SECOND renderer of the same derivation, alongside `toBwrapArgs`. That is
304
+ * the property task 4.7 asks for and the reason both live here: one
305
+ * computation, several consumers, so a macOS jail and a Linux jail cannot come
306
+ * to different conclusions about what a hook may see.
307
+ *
308
+ * **Order is semantic here for the same reason it is in `toBwrapArgs`, by a
309
+ * different mechanism.** SBPL is last-match-wins, so a read-write directory
310
+ * nested inside a read-only tree works exactly as bubblewrap's later-`--bind`-
311
+ * wins does. Measured 2026-08-27: with `state/` emitted after the module tree,
312
+ * a write to the tree gives `EPERM` and a write to `state/` succeeds.
313
+ *
314
+ * Three rules are not derived from the mount set, and each is a parity
315
+ * statement rather than a convenience:
316
+ *
317
+ * - `(import bsd.sb)` supplies what any process needs to start at all —
318
+ * the dyld shared cache, `file-read-metadata` for symlink traversal, the
319
+ * `logd`/`cfprefsd` lookups. Without it `bun` dies before `main` with no
320
+ * diagnostic (`SIGABRT`, no stderr, because stderr is denied too).
321
+ * - `(allow process*)` matches bubblewrap, which does not restrict `exec`
322
+ * either. A hook can run whatever it can READ, and what it can read is the
323
+ * mount set. Withholding the path is the boundary in both backends.
324
+ * - `(allow network*)` is D9: the network is not namespaced. D12 scopes
325
+ * reachability by withholding the credential, never by filtering packets.
326
+ *
327
+ * Everything else this profile does NOT say is deliberate. `/etc` is absent
328
+ * because it is absent from D9's table, so on macOS a hook that resolves a
329
+ * hostname is not stopped by THIS profile: `(allow network*)` lets it reach
330
+ * the system resolver, which answers out of process in mDNSResponder
331
+ * (measured 2026-08-30, task 4.13's suite). Withholding `/etc/resolv.conf`
332
+ * does withhold resolution on Linux, where the file is the resolver's
333
+ * configuration. Adding `/etc` here alone is the drift task 4.7 exists to
334
+ * prevent.
335
+ *
336
+ * @param set - Paths already resolved through `realpath`. Not optional: a rule
337
+ * naming an unresolved path does not match, and the failure is silent in
338
+ * both directions (D8). Measured: with the module tree named as `/tmp/…`
339
+ * rather than `/private/tmp/…` the rule does not apply, and bun cannot read
340
+ * the cwd it was handed.
341
+ */
342
+ export function toSandboxProfile(set: MountSet): string {
343
+ const lines = [
344
+ '(version 1)',
345
+ '(import "/System/Library/Sandbox/Profiles/bsd.sb")',
346
+ '(deny default)',
347
+ '(allow process*)',
348
+ '(allow network*)',
349
+ ];
350
+
351
+ // Every ANCESTOR of every mount, as a directory node and nothing more.
352
+ //
353
+ // This row has no bubblewrap counterpart and that is exactly why it exists.
354
+ // bubblewrap builds a new filesystem: to bind `/a/b/c` it must CREATE `/a/b`
355
+ // inside the namespace, so the parents come for free. `sandbox-exec` filters
356
+ // the tree that is already there and grants nothing implicitly, so every
357
+ // parent stays denied.
358
+ //
359
+ // What breaks is module resolution, and it breaks in a way that names none of
360
+ // this. Bun resolves a bare import by walking UP from the importing file
361
+ // testing each `<ancestor>/node_modules`. The shim's own `node_modules` is
362
+ // bound, but the directories BETWEEN are not, so the walk dies early, bun
363
+ // falls back to auto-install, and it tries to create `node_modules` in the
364
+ // read-only module tree. The message is `bun is unable to write files:
365
+ // PermissionDenied` — a write error for what is really a read denial three
366
+ // steps earlier. Measured 2026-08-27 by A/B on one variable: with a writable
367
+ // working directory the shim starts, with a read-only one it does not.
368
+ //
369
+ // `literal`, never `subpath`. A literal grant on a directory permits `stat`
370
+ // and `readdir` of that directory ALONE and confers nothing on the files in
371
+ // it. So `/var/celilo` becomes listable, which reveals that a file named
372
+ // `master.key` exists, and reading its bytes stays denied. That is the
373
+ // difference between D9's criterion holding and not, so do not "simplify"
374
+ // this to a subpath.
375
+ for (const path of ancestorsOf(set.entries)) {
376
+ lines.push(`(allow file-read* (literal ${sbplString(path)}))`);
377
+ }
378
+
379
+ for (const e of set.entries) {
380
+ // No tmpfs on macOS, and none is needed: `deny default` already makes the
381
+ // path unreadable, which is the privacy half of the row. The usability
382
+ // half — a working scratch directory — is what macOS does not get, so a
383
+ // hook writing to /tmp gets EPERM here and a discarded success on Linux.
384
+ // Louder than Linux rather than weaker, and named so nobody has to guess.
385
+ if (e.mode === 'tmpfs') {
386
+ lines.push(`; ${e.path}: no tmpfs backend; denied by default (${e.reason})`);
387
+ continue;
388
+ }
389
+ lines.push(`; ${e.reason}`);
390
+ if (e.mode === 'rw') {
391
+ lines.push(`(allow file-read* file-write* (subpath ${sbplString(e.path)}))`);
392
+ continue;
393
+ }
394
+ // The explicit deny makes `ro` mean read-only whatever preceded it, rather
395
+ // than relying on nothing earlier having granted write to a parent. That
396
+ // is true of today's derivation order and is not a property anyone should
397
+ // have to re-verify after editing it.
398
+ lines.push(`(deny file-write* (subpath ${sbplString(e.path)}))`);
399
+ lines.push(`(allow file-read* (subpath ${sbplString(e.path)}))`);
400
+ }
401
+
402
+ return `${lines.join('\n')}\n`;
403
+ }
404
+
405
+ /**
406
+ * Every directory strictly above one of these mounts, nearest-first order
407
+ * irrelevant, deduplicated. Excludes the mount paths themselves, which carry
408
+ * their own rules.
409
+ */
410
+ function ancestorsOf(entries: readonly MountEntry[]): string[] {
411
+ const own = new Set(entries.map((e) => e.path));
412
+ const found = new Set<string>();
413
+ for (const e of entries) {
414
+ let dir = dirname(e.path);
415
+ while (dir !== dirname(dir)) {
416
+ if (!own.has(dir)) found.add(dir);
417
+ dir = dirname(dir);
418
+ }
419
+ found.add('/');
420
+ }
421
+ return [...found];
422
+ }
423
+
424
+ /** A path as an SBPL string literal. */
425
+ function sbplString(path: string): string {
426
+ return `"${path.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
427
+ }
@@ -0,0 +1,350 @@
1
+ /**
2
+ * The remote-ops broker, tested through the REAL asking half (tasks 5.1/5.2,
3
+ * design D12).
4
+ *
5
+ * Every bridged case below runs the actual `@celilo/capabilities` primitive
6
+ * with `CELILO_HOOK_REMOTE_SOCKET` set — in a SEPARATE process
7
+ * (`test-fixtures/remote-bridge-probe.ts`), because that is the only topology
8
+ * that exists: the bridge blocks its caller in spawnSync while celilo's event
9
+ * loop answers the socket, so asking and answering can never share a process.
10
+ * The round trip therefore exercises what a hook exercises: the primitive's
11
+ * bridge detection, the spawned client, the socket, the Zod parse, the
12
+ * policy, and the broker-side primitive — with the one seam a unit test must
13
+ * inject, the runner that would otherwise run ssh.
14
+ */
15
+
16
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
17
+ import { existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
18
+ import { connect } from 'node:net';
19
+ import { tmpdir } from 'node:os';
20
+ import { join, resolve } from 'node:path';
21
+ import { type MockRunner, type RunResult, createMockRunner } from '@celilo/capabilities';
22
+ import { createCapturingLogger } from './logger';
23
+ import { type RemoteAccessPolicy, type RemoteBroker, startRemoteBroker } from './remote-broker';
24
+
25
+ const REMOTE_SOCKET_ENV = 'CELILO_HOOK_REMOTE_SOCKET';
26
+ const PROBE = resolve(__dirname, 'test-fixtures/remote-bridge-probe.ts');
27
+
28
+ const ALLOW_ALL: RemoteAccessPolicy = {
29
+ moduleId: 'caddy',
30
+ checkTarget: () => ({ allowed: true }),
31
+ };
32
+
33
+ const DENY_ALL: RemoteAccessPolicy = {
34
+ moduleId: 'caddy',
35
+ checkTarget: () => ({ allowed: false, message: 'policy says no' }),
36
+ };
37
+
38
+ /**
39
+ * The shape of the real policy's first rule (`services/remote-access.ts`):
40
+ * a request on the module's own credential is allowed, everything else is
41
+ * refused. Used to prove the broker computes and forwards
42
+ * `hasOwnCredential` — the decision itself is the policy's.
43
+ */
44
+ const OWN_CREDENTIAL_ONLY: RemoteAccessPolicy = {
45
+ moduleId: 'generic-cpanel-hosting-provider',
46
+ checkTarget: (_target, hasOwnCredential) =>
47
+ hasOwnCredential ? { allowed: true } : { allowed: false, message: 'fleet key refused' },
48
+ };
49
+
50
+ describe('remote-ops broker', () => {
51
+ let scratch: string;
52
+ let socketDir: string;
53
+ let stateDir: string;
54
+ let broker: RemoteBroker | undefined;
55
+ let runner: MockRunner;
56
+
57
+ beforeEach(() => {
58
+ scratch = mkdtempSync(join(tmpdir(), 'celilo-remote-broker-'));
59
+ // Short-named like the real run directory: sun_path is 104 bytes on macOS.
60
+ socketDir = mkdtempSync(join(tmpdir(), 'celilo-hook-'));
61
+ stateDir = join(scratch, 'state');
62
+ mkdirSync(stateDir, { recursive: true });
63
+ runner = createMockRunner([
64
+ { match: 'ssh', result: { ok: true, stdout: 'remote-ok', stderr: '' } },
65
+ ]);
66
+ });
67
+
68
+ afterEach(() => {
69
+ broker?.close();
70
+ broker = undefined;
71
+ rmSync(scratch, { recursive: true, force: true });
72
+ rmSync(socketDir, { recursive: true, force: true });
73
+ });
74
+
75
+ async function start(policy: RemoteAccessPolicy | undefined): Promise<void> {
76
+ const { logger } = createCapturingLogger();
77
+ broker = await startRemoteBroker({
78
+ socketDir,
79
+ policy,
80
+ readableRoots: [scratch],
81
+ writableRoots: [stateDir],
82
+ logger,
83
+ onActivity: () => {},
84
+ runner: runner.run,
85
+ });
86
+ }
87
+
88
+ /** Run one primitive from a hook-shaped process; return what it returned. */
89
+ async function fromHookProcess<T = RunResult>(instruction: Record<string, unknown>): Promise<T> {
90
+ const proc = Bun.spawn({
91
+ cmd: [process.execPath, PROBE, JSON.stringify(instruction)],
92
+ env: { ...process.env, [REMOTE_SOCKET_ENV]: (broker as RemoteBroker).socketPath },
93
+ stdout: 'pipe',
94
+ stderr: 'pipe',
95
+ });
96
+ const [stdout, stderr] = await Promise.all([
97
+ new Response(proc.stdout).text(),
98
+ new Response(proc.stderr).text(),
99
+ ]);
100
+ const code = await proc.exited;
101
+ if (code !== 0) throw new Error(`probe exited ${code}: ${stderr}`);
102
+ return JSON.parse(stdout) as T;
103
+ }
104
+
105
+ test('bridges remoteExec end to end, and the broker builds the ssh line itself', async () => {
106
+ await start(ALLOW_ALL);
107
+
108
+ const result = await fromHookProcess({
109
+ op: 'remoteExec',
110
+ target: { ipv4_address: '10.0.10.10' },
111
+ command: 'echo hi',
112
+ });
113
+
114
+ expect(result).toEqual({ ok: true, stdout: 'remote-ok', stderr: '' });
115
+ expect(runner.calls).toHaveLength(1);
116
+ expect(runner.calls[0]?.cmd).toContain('root@10.0.10.10');
117
+ expect(runner.calls[0]?.cmd).toContain("'echo hi'");
118
+ });
119
+
120
+ test('sugar primitives ride the bridge with no awareness of their own', async () => {
121
+ await start(ALLOW_ALL);
122
+
123
+ const result = await fromHookProcess({
124
+ op: 'serviceCtl',
125
+ target: { ipv4_address: '10.0.10.10' },
126
+ unit: 'caddy',
127
+ action: 'restart',
128
+ });
129
+
130
+ expect(result.ok).toBe(true);
131
+ expect(runner.calls[0]?.cmd).toContain('systemctl restart caddy');
132
+ });
133
+
134
+ test('a policy refusal comes back as the failed RunResult, and nothing runs', async () => {
135
+ await start(DENY_ALL);
136
+
137
+ const result = await fromHookProcess({
138
+ op: 'remoteExec',
139
+ target: { ipv4_address: '10.0.30.7' },
140
+ command: 'id -un',
141
+ });
142
+
143
+ expect(result.ok).toBe(false);
144
+ expect(result.stderr).toContain('policy says no');
145
+ expect(runner.calls).toHaveLength(0);
146
+ });
147
+
148
+ test('no policy means deny, naming the gap (Rule 6.4)', async () => {
149
+ await start(undefined);
150
+
151
+ const result = await fromHookProcess({
152
+ op: 'remoteExec',
153
+ target: { ipv4_address: '10.0.10.10' },
154
+ command: 'echo hi',
155
+ });
156
+
157
+ expect(result.ok).toBe(false);
158
+ expect(result.stderr).toContain('without a remote-access policy');
159
+ expect(runner.calls).toHaveLength(0);
160
+ });
161
+
162
+ test('streamBackup writes only inside the writable roots', async () => {
163
+ await start(ALLOW_ALL);
164
+
165
+ const inside = await fromHookProcess({
166
+ op: 'streamBackup',
167
+ target: { ipv4_address: '10.0.10.10' },
168
+ producerCommand: 'cat /etc/x',
169
+ localPath: join(stateDir, 'x'),
170
+ });
171
+ expect(inside.ok).toBe(true);
172
+ // The broker realpaths before it binds the containment decision to the
173
+ // command, so assert on the resolved path (macOS: /var → /private/var).
174
+ expect(runner.calls[0]?.cmd).toContain(join(realpathSync(stateDir), 'x'));
175
+
176
+ const outside = await fromHookProcess({
177
+ op: 'streamBackup',
178
+ target: { ipv4_address: '10.0.10.10' },
179
+ producerCommand: 'cat /etc/x',
180
+ localPath: join(scratch, 'not-writable'),
181
+ });
182
+ expect(outside.ok).toBe(false);
183
+ expect(outside.stderr).toContain('writable directories');
184
+ expect(runner.calls).toHaveLength(1);
185
+ });
186
+
187
+ test('streamBackup refuses a symlink leaf inside a writable root', async () => {
188
+ // The shell's `>` follows a link, so `state/evil` → anywhere would aim
189
+ // celilo's write outside the roots. Inside the jail the link can be
190
+ // dangling (the target path does not exist THERE) and still resolve on
191
+ // the broker's side — which is exactly why the leaf is refused rather
192
+ // than resolved.
193
+ await start(ALLOW_ALL);
194
+ const { symlinkSync } = await import('node:fs');
195
+ symlinkSync('/var/celilo/master.key', join(stateDir, 'evil'));
196
+
197
+ const result = await fromHookProcess({
198
+ op: 'streamBackup',
199
+ target: { ipv4_address: '10.0.10.10' },
200
+ producerCommand: 'cat /etc/x',
201
+ localPath: join(stateDir, 'evil'),
202
+ });
203
+
204
+ expect(result.ok).toBe(false);
205
+ expect(result.stderr).toContain('symlink');
206
+ expect(runner.calls).toHaveLength(0);
207
+ });
208
+
209
+ test('streamRestore reads only inside the readable roots', async () => {
210
+ await start(ALLOW_ALL);
211
+ const source = join(scratch, 'payload.tar');
212
+ writeFileSync(source, 'bytes');
213
+
214
+ const inside = await fromHookProcess({
215
+ op: 'streamRestore',
216
+ target: { ipv4_address: '10.0.10.10' },
217
+ localPath: source,
218
+ consumerCommand: 'tar -xf -',
219
+ });
220
+ expect(inside.ok).toBe(true);
221
+
222
+ const outside = await fromHookProcess({
223
+ op: 'streamRestore',
224
+ target: { ipv4_address: '10.0.10.10' },
225
+ localPath: '/etc/passwd',
226
+ consumerCommand: 'cat',
227
+ });
228
+ expect(outside.ok).toBe(false);
229
+ expect(outside.stderr).toContain('readable directories');
230
+ });
231
+
232
+ test('an identityFile crosses as content, is materialised for the call, and is removed', async () => {
233
+ // A target carrying its own credential reaches the policy with
234
+ // hasOwnCredential=true, which the real policy's first rule allows.
235
+ await start(OWN_CREDENTIAL_ONLY);
236
+ const keyPath = join(scratch, 'own-key');
237
+ writeFileSync(keyPath, 'not-a-real-key\n');
238
+
239
+ const result = await fromHookProcess({
240
+ op: 'remoteExec',
241
+ target: { ipv4_address: '198.51.100.7', user: 'peba-hosting', identityFile: keyPath },
242
+ command: 'echo hi',
243
+ });
244
+
245
+ expect(result.ok).toBe(true);
246
+ const cmd = runner.calls[0]?.cmd ?? '';
247
+ const materialized = cmd.match(/-i (\S*celilo-hook-identity-[^ ]*)/)?.[1];
248
+ expect(materialized).toBeDefined();
249
+ expect(materialized).not.toBe(keyPath);
250
+ expect(existsSync(materialized as string)).toBe(false);
251
+ });
252
+
253
+ test("installAuthorizedKey is brokered and its password counts as the module's own credential", async () => {
254
+ runner = createMockRunner([
255
+ { match: 'ssh-copy-id', result: { ok: true, stdout: 'installed', stderr: '' } },
256
+ ]);
257
+ // The password IS the credential: the broker reports hasOwnCredential
258
+ // and the policy's own-credential rule is what allows the call.
259
+ await start(OWN_CREDENTIAL_ONLY);
260
+
261
+ const result = await fromHookProcess({
262
+ op: 'installAuthorizedKey',
263
+ target: { ipv4_address: '198.51.100.7', user: 'peba-hosting', port: 7822 },
264
+ password: 'hunter2',
265
+ });
266
+
267
+ expect(result.ok).toBe(true);
268
+ expect(runner.calls[0]?.cmd).toContain('ssh-copy-id');
269
+ expect(runner.calls[0]?.cmd).toContain('peba-hosting@198.51.100.7');
270
+ // The password rides the child environment, never the command line.
271
+ expect(runner.calls[0]?.cmd).not.toContain('hunter2');
272
+ });
273
+
274
+ test('probeHttp asks the broker about the target and honours a refusal', async () => {
275
+ await start(DENY_ALL);
276
+
277
+ const refused = await fromHookProcess<{ healthy: boolean; failure?: string; detail: string }>({
278
+ op: 'probeHttpRefused',
279
+ target: { ipv4_address: '10.0.10.10' },
280
+ });
281
+
282
+ expect(refused.healthy).toBe(false);
283
+ expect(refused.failure).toBe('refused');
284
+ expect(refused.detail).toContain('policy says no');
285
+ });
286
+
287
+ test('probeHttp fetches when the broker allows the target', async () => {
288
+ await start(ALLOW_ALL);
289
+
290
+ const result = await fromHookProcess<{ healthy: boolean; detail: string }>({
291
+ op: 'probeHttpAllowed',
292
+ target: { ipv4_address: '10.0.10.10' },
293
+ });
294
+
295
+ expect(result).toEqual({ healthy: true, detail: 'GET http://10.0.10.10:80/ → 200' });
296
+ });
297
+
298
+ test('opts.env is refused on the asking side before anything crosses', async () => {
299
+ await start(ALLOW_ALL);
300
+
301
+ const result = await fromHookProcess({
302
+ op: 'remoteExec',
303
+ target: { ipv4_address: '10.0.10.10' },
304
+ command: 'echo hi',
305
+ opts: { env: { LD_PRELOAD: '/tmp/evil.so' } },
306
+ });
307
+
308
+ expect(result.ok).toBe(false);
309
+ expect(result.stderr).toContain('opts.env does not cross');
310
+ expect(runner.calls).toHaveLength(0);
311
+ });
312
+
313
+ test('stop() refuses further operations, which is what makes a kill real', async () => {
314
+ await start(ALLOW_ALL);
315
+ broker?.stop();
316
+
317
+ const result = await fromHookProcess({
318
+ op: 'remoteExec',
319
+ target: { ipv4_address: '10.0.10.10' },
320
+ command: 'echo hi',
321
+ });
322
+
323
+ expect(result.ok).toBe(false);
324
+ expect(result.stderr).toContain('Hook run has ended');
325
+ expect(runner.calls).toHaveLength(0);
326
+ });
327
+
328
+ test('a malformed request line is answered, not hung on', async () => {
329
+ await start(ALLOW_ALL);
330
+
331
+ const answer = await new Promise<string>((resolvePromise, reject) => {
332
+ const socket = connect((broker as RemoteBroker).socketPath);
333
+ socket.setEncoding('utf-8');
334
+ let buffer = '';
335
+ socket.on('connect', () => socket.write('this is not JSON\n'));
336
+ socket.on('data', (chunk: string) => {
337
+ buffer += chunk;
338
+ if (buffer.includes('\n')) {
339
+ socket.end();
340
+ resolvePromise(buffer);
341
+ }
342
+ });
343
+ socket.on('error', reject);
344
+ });
345
+
346
+ const parsed = JSON.parse(answer) as { ok: boolean; stderr: string };
347
+ expect(parsed.ok).toBe(false);
348
+ expect(parsed.stderr).toContain('malformed request');
349
+ });
350
+ });