@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.
- package/CELILO_CORE_MODULES.md +2 -2
- package/CELILO_SUBSYSTEMS.md +26 -3
- package/README.md +0 -2
- package/drizzle/0030_drop_module_builds_environment.sql +8 -0
- package/drizzle/meta/_journal.json +8 -1
- package/package.json +3 -3
- package/src/capabilities/public-web-publish.test.ts +18 -0
- package/src/cli/commands/alerts-sweep.ts +3 -0
- package/src/cli/commands/monitor.ts +15 -2
- package/src/cli/commands/system-doctor.test.ts +121 -1
- package/src/cli/commands/system-doctor.ts +151 -1
- package/src/cli/completion.ts +9 -2
- package/src/cli/index.ts +1 -1
- package/src/console/control-plane-boundary.test.ts +82 -4
- package/src/db/schema.ts +0 -1
- package/src/hooks/capability-loader.ts +15 -2
- package/src/hooks/executor.ts +116 -17
- package/src/hooks/hook-jail-toolchain-reach.test.ts +273 -0
- package/src/hooks/hook-jail-unreachability.test.ts +77 -26
- package/src/hooks/hook-protocol.ts +44 -0
- package/src/hooks/hook-runner-entry.ts +23 -0
- package/src/hooks/hook-runner.ts +10 -0
- package/src/hooks/hook-trespass.test.ts +74 -12
- package/src/hooks/jail-browser-launch-flags.test.ts +34 -0
- package/src/hooks/jail.test.ts +92 -0
- package/src/hooks/jail.ts +304 -32
- package/src/hooks/mount-set.test.ts +116 -7
- package/src/hooks/mount-set.ts +189 -20
- package/src/hooks/remote-broker.test.ts +350 -0
- package/src/hooks/remote-broker.ts +404 -0
- package/src/hooks/run-named-hook.ts +2 -0
- package/src/hooks/test-fixtures/jail-probe-hook.ts +14 -1
- package/src/hooks/test-fixtures/jail-toolchain-hook.ts +227 -0
- package/src/hooks/test-fixtures/remote-bridge-probe.ts +82 -0
- package/src/hooks/unjailed-lint.test.ts +254 -0
- package/src/hooks/unjailed-lint.ts +395 -0
- package/src/policy/module-business-baseline.ts +13 -1
- package/src/policy/module-script-scan.ts +60 -1
- package/src/policy/no-hand-built-ssh.test.ts +39 -1
- package/src/policy/no-module-business-in-core.test.ts +1 -1
- package/src/services/alerting/coverage-source.test.ts +86 -0
- package/src/services/alerting/coverage-source.ts +11 -1
- package/src/services/alerting/hook-jail.test.ts +66 -0
- package/src/services/alerting/hook-jail.ts +70 -0
- package/src/services/alerting/run-monitor.test.ts +62 -0
- package/src/services/alerting/run-monitor.ts +12 -0
- package/src/services/alerting/sweep-runner.test.ts +1 -0
- package/src/services/backup-create.ts +3 -0
- package/src/services/backup-restore.ts +2 -0
- package/src/services/control-plane-bootstrap.test.ts +177 -0
- package/src/services/control-plane-bootstrap.ts +176 -0
- package/src/services/control-plane-health.test.ts +66 -0
- package/src/services/control-plane-health.ts +67 -0
- package/src/services/deploy-ansible.ts +9 -1
- package/src/services/deployed-systems.ts +12 -0
- package/src/services/dns-discovery.test.ts +93 -0
- package/src/services/dns-discovery.ts +92 -0
- package/src/services/fleet-checks.ts +6 -2
- package/src/services/health-runner.ts +36 -3
- package/src/services/module-build.test.ts +1 -64
- package/src/services/module-build.ts +10 -86
- package/src/services/module-deploy.ts +71 -0
- package/src/services/remote-access.test.ts +223 -0
- package/src/services/remote-access.ts +149 -0
- package/src/services/restore-from-file.ts +6 -1
- package/src/services/static-content-converge.test.ts +338 -0
- package/src/services/static-content-converge.ts +299 -0
package/src/hooks/mount-set.ts
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
|
20
|
-
import type
|
|
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
|
-
//
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
+
});
|