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