@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/jail.ts
CHANGED
|
@@ -21,25 +21,82 @@
|
|
|
21
21
|
* reads.
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
|
-
import { execFileSync } from 'node:child_process';
|
|
24
|
+
import { execFileSync, spawnSync } from 'node:child_process';
|
|
25
25
|
import type { Dirent } from 'node:fs';
|
|
26
26
|
import {
|
|
27
27
|
existsSync,
|
|
28
28
|
mkdirSync,
|
|
29
|
+
mkdtempSync,
|
|
29
30
|
readFileSync,
|
|
30
31
|
readdirSync,
|
|
31
32
|
realpathSync,
|
|
32
33
|
renameSync,
|
|
34
|
+
rmSync,
|
|
33
35
|
statSync,
|
|
34
36
|
writeFileSync,
|
|
35
37
|
} from 'node:fs';
|
|
36
|
-
import { hostname } from 'node:os';
|
|
38
|
+
import { hostname, tmpdir } from 'node:os';
|
|
37
39
|
import { dirname, join } from 'node:path';
|
|
38
40
|
import { getDataDir } from '../config/paths';
|
|
39
|
-
import {
|
|
41
|
+
import { HOOK_SOCKET_ENV } from './hook-protocol';
|
|
42
|
+
import {
|
|
43
|
+
type MountSet,
|
|
44
|
+
type MountSetRequest,
|
|
45
|
+
deriveMountSet,
|
|
46
|
+
toBwrapArgs,
|
|
47
|
+
toSandboxProfile,
|
|
48
|
+
} from './mount-set';
|
|
49
|
+
import { JAIL_PROVIDED_PATHS } from './unjailed-lint';
|
|
40
50
|
|
|
41
|
-
/**
|
|
42
|
-
|
|
51
|
+
/**
|
|
52
|
+
* Which jail celilo can build here. `none` means the hook runs unjailed.
|
|
53
|
+
*
|
|
54
|
+
* `sandbox-exec` is in the union ahead of its backend (task 4.8) because the
|
|
55
|
+
* POLICY for it is decided and landed first (ce-29z): `auto` defers on it
|
|
56
|
+
* until D14's declared-path mounts exist, and the planner below enforces
|
|
57
|
+
* that whether or not a platform reports the backend yet.
|
|
58
|
+
*/
|
|
59
|
+
export type JailBackend = 'bubblewrap' | 'sandbox-exec' | 'none';
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Whether `auto` may jail through `sandbox-exec`.
|
|
63
|
+
*
|
|
64
|
+
* False on purpose, and the load-bearing fact of ce-29z. Measured there,
|
|
65
|
+
* same hardware, one variable (`CELILO_HOOK_JAIL=off` equals main's
|
|
66
|
+
* behaviour): under bubblewrap a fixture hook's write to an undeclared host
|
|
67
|
+
* path lands in the private tmpfs (D13b) and succeeds silently, while under
|
|
68
|
+
* `sandbox-exec` the same write is `EPERM` — the hook exits non-zero and the
|
|
69
|
+
* full `apps/celilo` suite goes red on macOS where it is green on Linux CI.
|
|
70
|
+
* Sandbox-exec DENIES rather than masks, so the macOS jail is stricter than
|
|
71
|
+
* the Linux one for exactly the paths nobody declared.
|
|
72
|
+
*
|
|
73
|
+
* The mechanism that declares them is D14 (`type: path` on a manifest
|
|
74
|
+
* variable), and D14 is not built speculatively — task 4.2a measured the
|
|
75
|
+
* population and found no consumer that survives D9b. So `auto` defers on
|
|
76
|
+
* this backend until that mechanism lands; flip this to `true` as part of
|
|
77
|
+
* that landing, never before. `CELILO_HOOK_JAIL=required` bypasses the
|
|
78
|
+
* deferral: it is the operator's explicit act (D8), and an operator who sets
|
|
79
|
+
* it on macOS has opted into the strictness.
|
|
80
|
+
*/
|
|
81
|
+
const SANDBOX_EXEC_AUTO_JAIL_ENABLED = false;
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Why an `auto` sandbox-exec spawn was deferred. Operator-facing: it names
|
|
85
|
+
* the decision and the one way past it. Exported because `system doctor`
|
|
86
|
+
* renders the same deferral from (policy, availability) alone, where no plan
|
|
87
|
+
* — and so no plan reason — exists yet.
|
|
88
|
+
*/
|
|
89
|
+
export const SANDBOX_EXEC_AUTO_DEFERRED_REASON =
|
|
90
|
+
'macOS hooks run unjailed: sandbox-exec denies (EPERM) a hook write to any host path the mount set does not declare, where bubblewrap masks it into its private tmpfs — undeclared config paths would break module hooks on this host. The declaration mechanism (D14, manifest `type: path`) is not built, so auto defers on this backend (ce-29z). Set CELILO_HOOK_JAIL=required to jail anyway.';
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Does `auto` defer on this backend? Shared by the planner and by
|
|
94
|
+
* `system doctor`, so the doctor cannot report a jail the executor will not
|
|
95
|
+
* build.
|
|
96
|
+
*/
|
|
97
|
+
export function autoJailDefers(availability: JailAvailability): boolean {
|
|
98
|
+
return availability.backend === 'sandbox-exec' && !SANDBOX_EXEC_AUTO_JAIL_ENABLED;
|
|
99
|
+
}
|
|
43
100
|
|
|
44
101
|
/**
|
|
45
102
|
* What the operator asked for. `CELILO_HOOK_JAIL`, and the default is `auto`.
|
|
@@ -75,13 +132,25 @@ export interface JailPlan {
|
|
|
75
132
|
*
|
|
76
133
|
* bubblewrap fails the whole jail on a bind whose SOURCE is missing, and
|
|
77
134
|
* several rows are legitimately absent: `/lib64` does not exist on arm64,
|
|
78
|
-
* `<module>/generated` only appears once celilo has generated
|
|
79
|
-
*
|
|
135
|
+
* and `<module>/generated` only appears once celilo has generated
|
|
136
|
+
* something. Dropping them is the caller's job rather
|
|
80
137
|
* than the derivation's, which is why they are reported rather than silently
|
|
81
138
|
* filtered — a contract input landing here is a real defect and this is
|
|
82
139
|
* where it becomes visible.
|
|
83
140
|
*/
|
|
84
141
|
readonly skipped: readonly string[];
|
|
142
|
+
/**
|
|
143
|
+
* The directory to spawn the child in, when the backend cannot set it itself.
|
|
144
|
+
*
|
|
145
|
+
* bubblewrap takes `--chdir`, so its plans leave this absent and the child
|
|
146
|
+
* inherits celilo's cwd — which the jail has replaced anyway.
|
|
147
|
+
* `sandbox-exec` has no equivalent, so the child inherits the OPERATOR's cwd
|
|
148
|
+
* and the profile does not name it. Measured 2026-08-27: `bun` reads its
|
|
149
|
+
* working directory before running anything, so a cwd outside the profile
|
|
150
|
+
* kills it with `error: An unknown error occurred (Unexpected)` and no
|
|
151
|
+
* further diagnostic. Naming the mount set's `chdir` here is the fix.
|
|
152
|
+
*/
|
|
153
|
+
readonly cwd?: string;
|
|
85
154
|
}
|
|
86
155
|
|
|
87
156
|
/**
|
|
@@ -104,7 +173,17 @@ export interface JailPlan {
|
|
|
104
173
|
* `--new-session` is NOT here. It defends against TIOCSTI injection into a
|
|
105
174
|
* controlling terminal, and the child is spawned with `stdout: 'pipe'` and no
|
|
106
175
|
* tty, so there is nothing to inject into.
|
|
176
|
+
*
|
|
177
|
+
* The `--proc`/`--dev` pairs are BUILT from `JAIL_PROVIDED_PATHS`
|
|
178
|
+
* (unjailed-lint.ts), the same list the advisory lint reads, so the spawn and
|
|
179
|
+
* the lint cannot disagree about what the namespace itself provides. Adding a
|
|
180
|
+
* path to that constant without a flag here is a compile error.
|
|
107
181
|
*/
|
|
182
|
+
const JAIL_PROVIDED_FLAGS: Record<(typeof JAIL_PROVIDED_PATHS)[number], string> = {
|
|
183
|
+
'/proc': '--proc',
|
|
184
|
+
'/dev': '--dev',
|
|
185
|
+
};
|
|
186
|
+
|
|
108
187
|
const JAIL_NAMESPACE_ARGS = [
|
|
109
188
|
'--unshare-user',
|
|
110
189
|
'--unshare-ipc',
|
|
@@ -113,10 +192,7 @@ const JAIL_NAMESPACE_ARGS = [
|
|
|
113
192
|
// The one unshare that legitimately may be unavailable on an older kernel.
|
|
114
193
|
'--unshare-cgroup-try',
|
|
115
194
|
'--die-with-parent',
|
|
116
|
-
|
|
117
|
-
'/proc',
|
|
118
|
-
'--dev',
|
|
119
|
-
'/dev',
|
|
195
|
+
...JAIL_PROVIDED_PATHS.flatMap((path) => [JAIL_PROVIDED_FLAGS[path], path]),
|
|
120
196
|
] as const;
|
|
121
197
|
|
|
122
198
|
/**
|
|
@@ -131,6 +207,24 @@ const JAIL_NAMESPACE_ARGS = [
|
|
|
131
207
|
*/
|
|
132
208
|
const BWRAP = 'bwrap';
|
|
133
209
|
|
|
210
|
+
/**
|
|
211
|
+
* The macOS jail builder.
|
|
212
|
+
*
|
|
213
|
+
* Pinned absolute, unlike `BWRAP`. There is no AppArmor-style profile to miss
|
|
214
|
+
* here, so `PATH` buys nothing and costs the one thing that matters: a
|
|
215
|
+
* `sandbox-exec` earlier on `PATH` would be handed every hook's profile and
|
|
216
|
+
* could report a jail it never built. The probe below would catch that, and
|
|
217
|
+
* pinning means it never has to.
|
|
218
|
+
*/
|
|
219
|
+
const SANDBOX_EXEC = '/usr/bin/sandbox-exec';
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* The runner shim, named here as well as in the executor because the probe has
|
|
223
|
+
* to spawn the REAL one. A probe that starts a smaller program answers a
|
|
224
|
+
* question about a smaller program.
|
|
225
|
+
*/
|
|
226
|
+
const HOOK_RUNNER_PATH = join(import.meta.dir, 'hook-runner.ts');
|
|
227
|
+
|
|
134
228
|
/** How long to wait for the probe before calling the backend unavailable. */
|
|
135
229
|
const PROBE_TIMEOUT_MS = 10_000;
|
|
136
230
|
|
|
@@ -158,17 +252,7 @@ export function detectJailBackend(): JailAvailability {
|
|
|
158
252
|
}
|
|
159
253
|
|
|
160
254
|
function probeJailBackend(): JailAvailability {
|
|
161
|
-
if (process.platform === 'darwin')
|
|
162
|
-
// `sandbox-exec` is measured working (D8) but the backend that generates
|
|
163
|
-
// its profile is task 4.8 and is not built. Claiming macOS is jailed
|
|
164
|
-
// because the tool exists would be the fail-open jail D8 calls worse than
|
|
165
|
-
// no jail at all.
|
|
166
|
-
return {
|
|
167
|
-
backend: 'none',
|
|
168
|
-
reason:
|
|
169
|
-
'macOS has no hook jail yet: the sandbox-exec backend is designed but not built (hook-process-boundary task 4.8). Hooks run unjailed on this host.',
|
|
170
|
-
};
|
|
171
|
-
}
|
|
255
|
+
if (process.platform === 'darwin') return probeSandboxExec();
|
|
172
256
|
if (process.platform !== 'linux') {
|
|
173
257
|
return {
|
|
174
258
|
backend: 'none',
|
|
@@ -188,6 +272,118 @@ function probeJailBackend(): JailAvailability {
|
|
|
188
272
|
}
|
|
189
273
|
}
|
|
190
274
|
|
|
275
|
+
/**
|
|
276
|
+
* What the probe plants and then tries to read. Its ABSENCE from the output is
|
|
277
|
+
* the evidence, so it is a string nothing else would print.
|
|
278
|
+
*/
|
|
279
|
+
const SANDBOX_PROBE_MARKER = 'celilo-jail-probe-reached-the-planted-file';
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Can this Mac jail? Measured by proving a DENIAL, never by finding the binary.
|
|
283
|
+
*
|
|
284
|
+
* This probe is shaped differently from bubblewrap's and the difference is the
|
|
285
|
+
* point. bubblewrap FAILS when it cannot build a namespace, loudly, with one of
|
|
286
|
+
* four distinct denials — so running it at all is the measurement.
|
|
287
|
+
* `sandbox-exec` does not fail. A profile it cannot apply to a path is simply
|
|
288
|
+
* not applied: the access succeeds and nothing anywhere reports an error (D8,
|
|
289
|
+
* measured, and re-measured 2026-08-27 in both directions — a rule naming
|
|
290
|
+
* `/tmp/x` where the kernel sees `/private/tmp/x` neither denies nor grants).
|
|
291
|
+
*
|
|
292
|
+
* So "sandbox-exec exists" is not evidence of anything, and neither is "the
|
|
293
|
+
* profile parsed". The only honest question is whether a file this probe
|
|
294
|
+
* planted, outside the profile, is actually unreachable from inside it. That is
|
|
295
|
+
* CLAUDE.md's reach probe applied to the jail itself: plant a marker, run the
|
|
296
|
+
* REAL machinery, and read which markers fired.
|
|
297
|
+
*
|
|
298
|
+
* Three outcomes, and the middle one is the one worth having:
|
|
299
|
+
*
|
|
300
|
+
* - the read was refused → `sandbox-exec`, the jail is real
|
|
301
|
+
* - the read SUCCEEDED → `none`, and the reason says fail-open.
|
|
302
|
+
* A jail that is believed and absent is worse than no jail (D8).
|
|
303
|
+
* - `sandbox-exec` never ran → `none`, with its own message
|
|
304
|
+
*/
|
|
305
|
+
function probeSandboxExec(): JailAvailability {
|
|
306
|
+
let scratch: string | undefined;
|
|
307
|
+
try {
|
|
308
|
+
scratch = mkdtempSync(join(realpathOrSelf(tmpdir()), 'celilo-jail-probe-'));
|
|
309
|
+
const allowed = join(scratch, 'allowed');
|
|
310
|
+
const planted = join(scratch, 'planted');
|
|
311
|
+
mkdirSync(join(allowed, 'state'), { recursive: true });
|
|
312
|
+
writeFileSync(planted, SANDBOX_PROBE_MARKER);
|
|
313
|
+
|
|
314
|
+
// The REAL derivation, the REAL renderer, and the REAL shim path, so this
|
|
315
|
+
// clears the same bar the spawn does. Every shortcut here has already been
|
|
316
|
+
// measured to matter: a mount set built around `bun -e` omits the shim's
|
|
317
|
+
// `node_modules` rows, and the runtime then starts under the probe's
|
|
318
|
+
// profile and dies under the executor's.
|
|
319
|
+
const profile = toSandboxProfile(
|
|
320
|
+
deriveMountSet({
|
|
321
|
+
modulePath: allowed,
|
|
322
|
+
stateDir: join(allowed, 'state'),
|
|
323
|
+
socketDir: allowed,
|
|
324
|
+
runtimePath: process.execPath,
|
|
325
|
+
runnerPath: HOOK_RUNNER_PATH,
|
|
326
|
+
runtimeModulePaths: runtimeModulePathsFor(HOOK_RUNNER_PATH),
|
|
327
|
+
pathInputs: [],
|
|
328
|
+
}),
|
|
329
|
+
);
|
|
330
|
+
const run = (cmd: readonly string[]) =>
|
|
331
|
+
spawnSync(SANDBOX_EXEC, ['-p', profile, '--', ...cmd], {
|
|
332
|
+
cwd: allowed,
|
|
333
|
+
encoding: 'utf-8',
|
|
334
|
+
timeout: PROBE_TIMEOUT_MS,
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
// 1. Can the RUNTIME start? Spawn the actual shim with no socket in its
|
|
338
|
+
// environment: it refuses immediately with a known message, having
|
|
339
|
+
// already loaded itself, `@celilo/capabilities` and Zod. That is the
|
|
340
|
+
// whole of the startup path, and it is where a jail that denies the
|
|
341
|
+
// runtime something it needs shows up.
|
|
342
|
+
const started = run([process.execPath, HOOK_RUNNER_PATH]);
|
|
343
|
+
if (started.error) throw started.error;
|
|
344
|
+
if (!started.stderr?.includes(HOOK_SOCKET_ENV)) {
|
|
345
|
+
return {
|
|
346
|
+
backend: 'none',
|
|
347
|
+
reason: `sandbox-exec built a jail the hook runner cannot start in, so hooks run unjailed rather than failing one at a time. The runner exited ${started.status} saying: ${(started.stderr || started.stdout || '').trim().split('\n')[0] || '(nothing)'}`,
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// 2. Does a DENIAL actually apply? `sandbox-exec` never fails on a rule it
|
|
352
|
+
// cannot match — the access simply succeeds and nothing reports an
|
|
353
|
+
// error (D8, measured). So the profile is not evidence; a file this
|
|
354
|
+
// probe planted outside the mount set, and could not read, is.
|
|
355
|
+
const denied = run([
|
|
356
|
+
process.execPath,
|
|
357
|
+
'-e',
|
|
358
|
+
`try{require('node:fs').readFileSync(${JSON.stringify(planted)});console.log('${SANDBOX_PROBE_MARKER}')}catch{console.log('refused')}`,
|
|
359
|
+
]);
|
|
360
|
+
if (denied.error) throw denied.error;
|
|
361
|
+
if ((denied.stdout ?? '').includes(SANDBOX_PROBE_MARKER)) {
|
|
362
|
+
return {
|
|
363
|
+
backend: 'none',
|
|
364
|
+
reason:
|
|
365
|
+
'sandbox-exec ran but did not enforce the profile: a file outside the mount set was still readable. Hooks run unjailed rather than appearing jailed and not being, which is the one outcome worse than no jail. This is usually an unresolved path in a rule — every path must be realpath()d before it is written.',
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
return { backend: 'sandbox-exec' };
|
|
370
|
+
} catch (error) {
|
|
371
|
+
const spawnFailure = (error as { code?: string } | null)?.code;
|
|
372
|
+
if (spawnFailure === 'ENOENT') {
|
|
373
|
+
return {
|
|
374
|
+
backend: 'none',
|
|
375
|
+
reason: `${SANDBOX_EXEC} is not on this macOS, so hooks run unjailed. It has carried a deprecation notice since 10.8; if Apple has removed it, this host has no hook jail backend.`,
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
return {
|
|
379
|
+
backend: 'none',
|
|
380
|
+
reason: `sandbox-exec could not run the probe, so hooks run unjailed: ${error instanceof Error ? error.message : String(error)}`,
|
|
381
|
+
};
|
|
382
|
+
} finally {
|
|
383
|
+
if (scratch) rmSync(scratch, { recursive: true, force: true });
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
191
387
|
/**
|
|
192
388
|
* Turn the probe's failure into something an operator can act on.
|
|
193
389
|
*
|
|
@@ -258,7 +454,6 @@ export function realpathRequest(request: MountSetRequest): MountSetRequest {
|
|
|
258
454
|
...input,
|
|
259
455
|
value: realpathOrSelf(input.value),
|
|
260
456
|
})),
|
|
261
|
-
sshDir: request.sshDir ? realpathOrSelf(request.sshDir) : undefined,
|
|
262
457
|
};
|
|
263
458
|
}
|
|
264
459
|
|
|
@@ -362,6 +557,14 @@ function realpathOrSelf(path: string): string {
|
|
|
362
557
|
* @param set - The derived mount set, or `undefined` when the caller has no
|
|
363
558
|
* module tree to jail (the executor's own fixtures, and the bus handler path).
|
|
364
559
|
*/
|
|
560
|
+
/**
|
|
561
|
+
* Why an `off` policy runs unjailed, shared with `celilo system doctor` (task
|
|
562
|
+
* 4.6) so the sentence the operator reads there is the sentence the record
|
|
563
|
+
* carries.
|
|
564
|
+
*/
|
|
565
|
+
export const JAIL_OFF_REASON =
|
|
566
|
+
'CELILO_HOOK_JAIL=off: the operator switched the hook jail off on this host.';
|
|
567
|
+
|
|
365
568
|
export function planJailedSpawn(
|
|
366
569
|
cmd: readonly string[],
|
|
367
570
|
set: MountSet | undefined,
|
|
@@ -374,7 +577,7 @@ export function planJailedSpawn(
|
|
|
374
577
|
cmd,
|
|
375
578
|
mode: 'unjailed',
|
|
376
579
|
backend: availability.backend,
|
|
377
|
-
reason:
|
|
580
|
+
reason: JAIL_OFF_REASON,
|
|
378
581
|
skipped: [],
|
|
379
582
|
};
|
|
380
583
|
}
|
|
@@ -391,18 +594,49 @@ export function planJailedSpawn(
|
|
|
391
594
|
return { cmd, mode: 'unjailed', backend: availability.backend, reason, skipped: [] };
|
|
392
595
|
}
|
|
393
596
|
|
|
597
|
+
// ce-29z: auto defers on sandbox-exec until D14 exists. Not an availability
|
|
598
|
+
// question — the backend is there; it is a policy one, which is why this
|
|
599
|
+
// sits in the planner rather than the probe.
|
|
600
|
+
if (policy === 'auto' && autoJailDefers(availability)) {
|
|
601
|
+
return {
|
|
602
|
+
cmd,
|
|
603
|
+
mode: 'unjailed',
|
|
604
|
+
backend: availability.backend,
|
|
605
|
+
reason: SANDBOX_EXEC_AUTO_DEFERRED_REASON,
|
|
606
|
+
skipped: [],
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
|
|
394
610
|
// A tmpfs needs no source — bubblewrap creates it — so it is never dropped.
|
|
611
|
+
// The note below (ce-29z, 2026-09) is why 'required' is the only policy that
|
|
612
|
+
// reaches the sandbox-exec arm while SANDBOX_EXEC_AUTO_JAIL_ENABLED is
|
|
613
|
+
// false: the flag flip is D14's landing, never 4.8's.
|
|
395
614
|
const present = set.entries.filter((e) => e.mode === 'tmpfs' || exists(e.path));
|
|
396
615
|
const skipped = set.entries.filter((e) => !present.includes(e)).map((e) => e.path);
|
|
616
|
+
const applied: MountSet = { ...set, entries: present };
|
|
617
|
+
|
|
618
|
+
// Dropping an absent path is a bubblewrap NEED — it fails the whole jail on a
|
|
619
|
+
// bind with no source — and is merely tidy for `sandbox-exec`, which happily
|
|
620
|
+
// names a path that is not there. Both backends drop the same rows anyway,
|
|
621
|
+
// because a mount set that means two things on two platforms is exactly the
|
|
622
|
+
// drift task 4.7 exists to prevent.
|
|
623
|
+
if (availability.backend === 'sandbox-exec') {
|
|
624
|
+
// `-p` rather than a profile FILE: nothing to create, nothing to clean
|
|
625
|
+
// up, and nothing whose own readability has to be reasoned about.
|
|
626
|
+
// `sandbox-exec` reads the profile before it applies the sandbox, so a
|
|
627
|
+
// file would not have needed to be in the mount set — but it would have
|
|
628
|
+
// needed a lifetime, and this has none.
|
|
629
|
+
return {
|
|
630
|
+
cmd: [SANDBOX_EXEC, '-p', toSandboxProfile(applied), '--', ...cmd],
|
|
631
|
+
mode: 'jailed',
|
|
632
|
+
backend: availability.backend,
|
|
633
|
+
skipped,
|
|
634
|
+
cwd: set.chdir,
|
|
635
|
+
};
|
|
636
|
+
}
|
|
397
637
|
|
|
398
638
|
return {
|
|
399
|
-
cmd: [
|
|
400
|
-
BWRAP,
|
|
401
|
-
...JAIL_NAMESPACE_ARGS,
|
|
402
|
-
...toBwrapArgs({ ...set, entries: present }),
|
|
403
|
-
'--',
|
|
404
|
-
...cmd,
|
|
405
|
-
],
|
|
639
|
+
cmd: [BWRAP, ...JAIL_NAMESPACE_ARGS, ...toBwrapArgs(applied), '--', ...cmd],
|
|
406
640
|
mode: 'jailed',
|
|
407
641
|
backend: availability.backend,
|
|
408
642
|
skipped,
|
|
@@ -423,6 +657,20 @@ export interface JailModeRecord {
|
|
|
423
657
|
*/
|
|
424
658
|
readonly host: string;
|
|
425
659
|
readonly recordedAt: string;
|
|
660
|
+
/**
|
|
661
|
+
* The jailed record this host most recently held, present only on an
|
|
662
|
+
* `unjailed` record that replaced one on the SAME host.
|
|
663
|
+
*
|
|
664
|
+
* This is what makes the jailed-to-unjailed transition durable. The write
|
|
665
|
+
* below overwrites the previous record, so without this field the file for
|
|
666
|
+
* a host that used to jail and has stopped reads identically to a Mac that
|
|
667
|
+
* never jailed — and the self-monitor (task 4.4) runs on a sweep, long
|
|
668
|
+
* after `recordJailMode`'s return value is gone.
|
|
669
|
+
*/
|
|
670
|
+
readonly lastJailed?: {
|
|
671
|
+
readonly backend: JailBackend;
|
|
672
|
+
readonly recordedAt: string;
|
|
673
|
+
};
|
|
426
674
|
}
|
|
427
675
|
|
|
428
676
|
/**
|
|
@@ -457,12 +705,14 @@ export function readJailMode(): JailModeRecord | undefined {
|
|
|
457
705
|
*/
|
|
458
706
|
export function recordJailMode(plan: JailPlan): { previous?: JailModeRecord } {
|
|
459
707
|
const previous = readJailMode();
|
|
708
|
+
const host = hostname();
|
|
460
709
|
const record: JailModeRecord = {
|
|
461
710
|
mode: plan.mode,
|
|
462
711
|
backend: plan.backend,
|
|
463
712
|
...(plan.reason ? { reason: plan.reason } : {}),
|
|
464
|
-
host
|
|
713
|
+
host,
|
|
465
714
|
recordedAt: new Date().toISOString(),
|
|
715
|
+
...(lastJailedFor(plan.mode, previous, host) ?? {}),
|
|
466
716
|
};
|
|
467
717
|
|
|
468
718
|
if (
|
|
@@ -489,3 +739,25 @@ export function recordJailMode(plan: JailPlan): { previous?: JailModeRecord } {
|
|
|
489
739
|
}
|
|
490
740
|
return { previous };
|
|
491
741
|
}
|
|
742
|
+
|
|
743
|
+
/**
|
|
744
|
+
* What an unjailed record remembers about the last time this host jailed.
|
|
745
|
+
*
|
|
746
|
+
* A record from a DIFFERENT host contributes nothing: that is the
|
|
747
|
+
* copied-between-boxes case the `host` field exists for, and reading it as a
|
|
748
|
+
* transition would alert on a move that regressed nothing. A jailed record
|
|
749
|
+
* remembers nothing either — re-jailing is the recovery, and a later
|
|
750
|
+
* regression should date from the NEW jailed record, not the first ever.
|
|
751
|
+
*/
|
|
752
|
+
function lastJailedFor(
|
|
753
|
+
mode: JailMode,
|
|
754
|
+
previous: JailModeRecord | undefined,
|
|
755
|
+
host: string,
|
|
756
|
+
): Pick<JailModeRecord, 'lastJailed'> | undefined {
|
|
757
|
+
if (mode !== 'unjailed' || !previous || previous.host !== host) return undefined;
|
|
758
|
+
if (previous.mode === 'jailed') {
|
|
759
|
+
return { lastJailed: { backend: previous.backend, recordedAt: previous.recordedAt } };
|
|
760
|
+
}
|
|
761
|
+
// unjailed → unjailed (a backend or reason change): the memory rides along.
|
|
762
|
+
return previous.lastJailed ? { lastJailed: previous.lastJailed } : undefined;
|
|
763
|
+
}
|
|
@@ -7,7 +7,14 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { describe, expect, test } from 'bun:test';
|
|
10
|
-
import {
|
|
10
|
+
import { BROWSER_ROOT } from '@celilo/capabilities';
|
|
11
|
+
import {
|
|
12
|
+
deriveMountSet,
|
|
13
|
+
forbiddenPaths,
|
|
14
|
+
isForbidden,
|
|
15
|
+
toBwrapArgs,
|
|
16
|
+
toSandboxProfile,
|
|
17
|
+
} from './mount-set';
|
|
11
18
|
|
|
12
19
|
const BASE = {
|
|
13
20
|
modulePath: '/var/celilo/modules/caddy',
|
|
@@ -136,13 +143,115 @@ describe('paths are identical inside and outside', () => {
|
|
|
136
143
|
});
|
|
137
144
|
});
|
|
138
145
|
|
|
139
|
-
describe('~/.ssh is stage
|
|
140
|
-
test('
|
|
141
|
-
|
|
142
|
-
|
|
146
|
+
describe('~/.ssh is never in the mount set (stage 3, D12)', () => {
|
|
147
|
+
test('no derivation produces an .ssh row', () => {
|
|
148
|
+
// Withholding the key is what turns the remote-ops broker's target check
|
|
149
|
+
// from a convention into a boundary: with no credential in the jail, a
|
|
150
|
+
// hand-built `ssh` cannot authenticate anywhere.
|
|
151
|
+
expect(pathsOf(BASE).some((p) => p.endsWith('/.ssh'))).toBe(false);
|
|
143
152
|
});
|
|
153
|
+
});
|
|
144
154
|
|
|
145
|
-
|
|
146
|
-
|
|
155
|
+
describe('the fleet browser is reachable, and only read-only (task 4.10)', () => {
|
|
156
|
+
test('BROWSER_ROOT is bound', () => {
|
|
157
|
+
// Task 4.10 names `~/.cache/ms-playwright`. That path is stale:
|
|
158
|
+
// `managed-browser-runtime` moved the browser into a celilo-owned tree,
|
|
159
|
+
// and `resolveBrowser()` — the thing a hook actually asks — returns
|
|
160
|
+
// `BROWSER_EXECUTABLE_PATH` under this root. Asserted against the exported
|
|
161
|
+
// constant rather than a literal, so a future move cannot leave this test
|
|
162
|
+
// passing about a directory nothing launches from.
|
|
163
|
+
expect(pathsOf(BASE)).toContain(BROWSER_ROOT);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test('read-only, because a hook has no business writing the shared install', () => {
|
|
167
|
+
expect(deriveMountSet(BASE).entries.find((e) => e.path === BROWSER_ROOT)?.mode).toBe('ro');
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test('the data directory beside it stays out', () => {
|
|
171
|
+
// The row is one named subdirectory, not `/var/lib/celilo`. Binding the
|
|
172
|
+
// parent would put `celilo.db` and `master.key` back inside the jail —
|
|
173
|
+
// the exact acceptance criterion D9 satisfies by absence.
|
|
174
|
+
expect(pathsOf(BASE)).not.toContain('/var/lib/celilo');
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
describe('the sandbox-exec profile renders the same set (task 4.8)', () => {
|
|
179
|
+
const profileOf = (r: Parameters<typeof deriveMountSet>[0] = BASE) =>
|
|
180
|
+
toSandboxProfile(deriveMountSet(r));
|
|
181
|
+
|
|
182
|
+
test('a read-only mount is denied write and allowed read, in that order', () => {
|
|
183
|
+
const lines = profileOf().split('\n');
|
|
184
|
+
const deny = lines.indexOf(`(deny file-write* (subpath "${BASE.modulePath}"))`);
|
|
185
|
+
const allow = lines.indexOf(`(allow file-read* (subpath "${BASE.modulePath}"))`);
|
|
186
|
+
expect(deny).toBeGreaterThan(-1);
|
|
187
|
+
// SBPL is last-match-wins, so the deny must come FIRST or it would revoke
|
|
188
|
+
// the read it is paired with.
|
|
189
|
+
expect(allow).toBeGreaterThan(deny);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test('a writable directory nested in the read-only tree comes after it', () => {
|
|
193
|
+
const lines = profileOf().split('\n');
|
|
194
|
+
expect(
|
|
195
|
+
lines.indexOf(`(allow file-read* file-write* (subpath "${BASE.stateDir}"))`),
|
|
196
|
+
).toBeGreaterThan(lines.indexOf(`(allow file-read* (subpath "${BASE.modulePath}"))`));
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test('the tmpfs row grants nothing: macOS has no tmpfs and deny-default covers it', () => {
|
|
200
|
+
// The row still appears, as a comment, so a reader of the profile can see
|
|
201
|
+
// that the derivation asked for something this backend cannot give.
|
|
202
|
+
const profile = profileOf();
|
|
203
|
+
expect(profile).toContain('; /tmp: no tmpfs backend');
|
|
204
|
+
expect(profile).not.toContain('(subpath "/tmp")');
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test('every ancestor is a literal, never a subpath', () => {
|
|
208
|
+
// A `subpath` grant on an ancestor would expose everything beneath it —
|
|
209
|
+
// `/var/celilo` holds master.key. `literal` permits stat and readdir of the
|
|
210
|
+
// directory node alone. This is the difference between D9's acceptance
|
|
211
|
+
// criterion holding and not.
|
|
212
|
+
const profile = profileOf();
|
|
213
|
+
expect(profile).toContain('(allow file-read* (literal "/var/celilo"))');
|
|
214
|
+
expect(profile).not.toContain('(allow file-read* (subpath "/var/celilo"))');
|
|
215
|
+
expect(profile).not.toContain('(subpath "/"))');
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test('a forbidden path never reaches the profile', () => {
|
|
219
|
+
const profile = profileOf({
|
|
220
|
+
...BASE,
|
|
221
|
+
pathInputs: [{ name: 'evil', value: '/usr/bin/bwrap', access: 'write' as const }],
|
|
222
|
+
});
|
|
223
|
+
expect(profile).not.toContain('(subpath "/usr/bin/bwrap")');
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test('a quote in a path cannot end the rule early', () => {
|
|
227
|
+
const profile = profileOf({ ...BASE, modulePath: '/var/celilo/modules/od"d' });
|
|
228
|
+
expect(profile).toContain('(subpath "/var/celilo/modules/od\\"d")');
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test('the network is allowed, because D9 does not namespace it', () => {
|
|
232
|
+
expect(profileOf()).toContain('(allow network*)');
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
describe('name resolution inside the jail (celilo#1225)', () => {
|
|
237
|
+
test('the resolver config is bound read-only', () => {
|
|
238
|
+
// Without these a hook resolves no NAME. getaddrinfo finds no nameserver
|
|
239
|
+
// and the call dies as ETIMEOUT, which reads as the remote endpoint being
|
|
240
|
+
// down. Measured on namecheap's validate_config against an endpoint that
|
|
241
|
+
// was up.
|
|
242
|
+
const set = deriveMountSet(BASE);
|
|
243
|
+
for (const path of ['/etc/resolv.conf', '/etc/nsswitch.conf', '/etc/hosts']) {
|
|
244
|
+
const row = set.entries.find((e) => e.path === path);
|
|
245
|
+
expect(row).toBeDefined();
|
|
246
|
+
expect(row?.mode).toBe('ro');
|
|
247
|
+
}
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test('binding the resolver does not bind the rest of /etc', () => {
|
|
251
|
+
// The acceptance criterion for this jail is absence. Naming three files
|
|
252
|
+
// must not become naming a directory.
|
|
253
|
+
const set = deriveMountSet(BASE);
|
|
254
|
+
expect(set.entries.some((e) => e.path === '/etc')).toBe(false);
|
|
255
|
+
expect(set.entries.some((e) => e.path === '/etc/shadow')).toBe(false);
|
|
147
256
|
});
|
|
148
257
|
});
|