@celilo/cli 1.13.0 → 2.0.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 +1 -1
- package/CELILO_SUBSYSTEMS.md +31 -5
- 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-helpers.test.ts +12 -6
- package/src/capabilities/public-web-publish.test.ts +42 -13
- package/src/capabilities/validation.test.ts +31 -0
- package/src/cli/commands/alerts-sweep.ts +3 -0
- package/src/cli/commands/console-get-chain.test.ts +96 -0
- package/src/cli/commands/console.ts +13 -5
- package/src/cli/commands/monitor.ts +15 -2
- package/src/cli/commands/notify-config.test.ts +79 -0
- package/src/cli/commands/notify-config.ts +13 -2
- package/src/cli/commands/system-doctor.test.ts +121 -1
- package/src/cli/commands/system-doctor.ts +151 -1
- package/src/cli/commands/system-ensure-fleet-key.ts +52 -0
- package/src/cli/completion.ts +10 -2
- package/src/cli/index.ts +7 -1
- package/src/console/closure.test.ts +76 -0
- package/src/console/closure.ts +87 -1
- package/src/console/control-plane-boundary.test.ts +82 -4
- package/src/console/projection.test.ts +63 -1
- package/src/console/projection.ts +39 -2
- package/src/db/schema.ts +0 -1
- package/src/hooks/capability-loader-control-plane-api.test.ts +124 -0
- package/src/hooks/capability-loader.ts +81 -10
- package/src/hooks/executor.ts +110 -17
- package/src/hooks/hook-jail-toolchain-reach.test.ts +224 -0
- package/src/hooks/hook-jail-unreachability.test.ts +28 -2
- 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 +9 -3
- package/src/hooks/jail-browser-launch-flags.test.ts +34 -0
- package/src/hooks/jail.test.ts +92 -0
- package/src/hooks/jail.ts +128 -11
- package/src/hooks/mount-set.test.ts +28 -6
- package/src/hooks/mount-set.ts +34 -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 +251 -0
- package/src/hooks/unjailed-lint.ts +395 -0
- package/src/manifest/contracts/v1.ts +22 -1
- package/src/manifest/validate.ts +25 -4
- package/src/module/web-root.ts +35 -0
- package/src/policy/module-business-baseline.ts +27 -3
- package/src/policy/module-script-scan.test.ts +22 -0
- package/src/policy/module-script-scan.ts +92 -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/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/api-principal-enrolment.test.ts +73 -0
- package/src/services/api-principal-enrolment.ts +55 -0
- package/src/services/backup-create.ts +36 -7
- package/src/services/backup-restore.ts +2 -0
- package/src/services/celilo-mgmt-hooks.test.ts +38 -79
- package/src/services/deploy-ansible.ts +9 -1
- package/src/services/fleet-key.test.ts +47 -0
- package/src/services/fleet-key.ts +75 -0
- package/src/services/health-runner.ts +2 -0
- package/src/services/module-build.test.ts +1 -64
- package/src/services/module-build.ts +10 -86
- package/src/services/module-deploy.ts +20 -0
- package/src/services/remote-access.test.ts +139 -0
- package/src/services/remote-access.ts +98 -0
- package/src/services/restore-from-file.ts +12 -6
- package/src/services/static-content-converge.test.ts +338 -0
- package/src/services/static-content-converge.ts +299 -0
- package/src/services/system-state-stage.test.ts +165 -0
- package/src/services/system-state-stage.ts +196 -0
|
@@ -0,0 +1,224 @@
|
|
|
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
|
+
const reach = (await executeHookScript(PROBE_HOOK, context, {
|
|
129
|
+
timeoutMs: 60_000,
|
|
130
|
+
idleTimeoutMs: 60_000,
|
|
131
|
+
jail: {
|
|
132
|
+
modulePath,
|
|
133
|
+
// The browser reaches the jail as a DECLARED PATH INPUT, which is the
|
|
134
|
+
// mechanism 4.10's fix would use. Binding it by editing the derivation
|
|
135
|
+
// and then asking the derivation whether it is bound would prove
|
|
136
|
+
// nothing.
|
|
137
|
+
pathInputs: PROBE_BROWSER
|
|
138
|
+
? [{ name: 'browser_executable', value: probeBrowserDir(), access: 'read' as const }]
|
|
139
|
+
: [],
|
|
140
|
+
},
|
|
141
|
+
})) as unknown as Reach;
|
|
142
|
+
|
|
143
|
+
return { reach, cleanup: () => rmSync(scratch, { recursive: true, force: true }) };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
describe.skipIf(!jailed)(`what a jailed hook can run (backend: ${availability.backend})`, () => {
|
|
147
|
+
// 30s, not bun's default 5s: the DNS probe deliberately waits for an answer
|
|
148
|
+
// that never comes, and a runner timeout would report as a suite failure
|
|
149
|
+
// rather than as the measurement it is.
|
|
150
|
+
test('measures the reach', async () => {
|
|
151
|
+
const { reach, cleanup } = await measureReach();
|
|
152
|
+
try {
|
|
153
|
+
// Printed unconditionally, and to stderr because bun swallows a passing
|
|
154
|
+
// test's stdout. This suite's value is the table, and a run that only
|
|
155
|
+
// says "1 pass" has thrown the finding away.
|
|
156
|
+
for (const [name, probe] of Object.entries(reach)) {
|
|
157
|
+
console.error(` ${probe.succeeded ? 'REACHED' : 'ABSENT '} ${name}: ${probe.detail}`);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ── The finding ────────────────────────────────────────────────────
|
|
161
|
+
// No shell, so `execSync` cannot run and neither can any hook that uses
|
|
162
|
+
// it. 22 module script files import `node:child_process`.
|
|
163
|
+
expect(reach.shell.succeeded).toBe(false);
|
|
164
|
+
expect(reach.exec_without_shell.succeeded).toBe(false);
|
|
165
|
+
|
|
166
|
+
// `~/.ssh` is bound read-only in stage 2 specifically so `remote.ts`
|
|
167
|
+
// keeps working. There is no `ssh` to hand that key to.
|
|
168
|
+
expect(reach.ssh.succeeded).toBe(false);
|
|
169
|
+
expect(reach.ansible.succeeded).toBe(false);
|
|
170
|
+
|
|
171
|
+
// Nothing binds the resolver's configuration.
|
|
172
|
+
expect(reach.resolv_conf.succeeded).toBe(false);
|
|
173
|
+
|
|
174
|
+
// ── Task 4.10 ──────────────────────────────────────────────────────
|
|
175
|
+
// `~/.cache/ms-playwright` is the path task 4.10 names and it is bound
|
|
176
|
+
// by nothing, deliberately: `managed-browser-runtime` moved the browser
|
|
177
|
+
// into a celilo-owned tree, so binding the cache would bind a directory
|
|
178
|
+
// nothing launches from.
|
|
179
|
+
expect(reach.playwright_cache.succeeded).toBe(false);
|
|
180
|
+
|
|
181
|
+
// Binding BROWSER_ROOT must not expose what sits beside it. This one IS
|
|
182
|
+
// asserted unconditionally: `master.key` is unreadable whether or not
|
|
183
|
+
// the browser directory exists on this host, so unlike the row below
|
|
184
|
+
// there is no configuration in which this passes vacuously.
|
|
185
|
+
expect(reach.data_dir_sibling.succeeded).toBe(false);
|
|
186
|
+
|
|
187
|
+
// `BROWSER_ROOT` is NOT asserted here, and the reason is worth stating
|
|
188
|
+
// because asserting it would be the trap this file exists to avoid.
|
|
189
|
+
// `deriveMountSet` now emits the row, but `planJailedSpawn` drops any
|
|
190
|
+
// row whose source is missing — so on a host with no provisioned
|
|
191
|
+
// browser this probe reports ABSENT for a reason that has nothing to do
|
|
192
|
+
// with the mount set, and an assertion either way would be measuring
|
|
193
|
+
// the host rather than the derivation. The row itself is asserted
|
|
194
|
+
// hermetically in `mount-set.test.ts`, where it can be.
|
|
195
|
+
|
|
196
|
+
// The nesting question, asserted only on a run that supplied a browser.
|
|
197
|
+
// Measured 2026-08-28 in `oven/bun:latest` on aarch64 with Debian's
|
|
198
|
+
// chromium: with its own sandbox Chromium will not start inside the
|
|
199
|
+
// jail, and `--no-sandbox` renders. That is task 4.10's prediction
|
|
200
|
+
// confirmed, and it says what the fix has to include.
|
|
201
|
+
if (PROBE_BROWSER) {
|
|
202
|
+
expect(reach.browser_binary_present.succeeded).toBe(true);
|
|
203
|
+
expect(reach.browser_sandboxed.succeeded).toBe(false);
|
|
204
|
+
expect(reach.browser_no_sandbox.succeeded).toBe(true);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// ── The guard ──────────────────────────────────────────────────────
|
|
208
|
+
// Opposite direction, and it must never flip. D9 predicts the exact
|
|
209
|
+
// edit that would flip it: someone reads the failures above, binds
|
|
210
|
+
// `/usr/bin` to fix them, and brings `bwrap` in with it. `isForbidden`
|
|
211
|
+
// compares whole paths, so a bind of the DIRECTORY passes that filter.
|
|
212
|
+
expect(reach.bwrap_present.succeeded).toBe(false);
|
|
213
|
+
} finally {
|
|
214
|
+
cleanup();
|
|
215
|
+
}
|
|
216
|
+
}, 30_000);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
describe.skipIf(jailed)('no jail on this host', () => {
|
|
220
|
+
test('says so rather than passing quietly', () => {
|
|
221
|
+
console.log(`hook jail unavailable, so 4.13's reach was NOT measured: ${availability.reason}`);
|
|
222
|
+
expect(availability.backend).toBe('none');
|
|
223
|
+
});
|
|
224
|
+
});
|
|
@@ -27,8 +27,11 @@
|
|
|
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
36
|
import { detectJailBackend } from './jail';
|
|
34
37
|
import { createCapturingLogger } from './logger';
|
|
@@ -50,6 +53,7 @@ interface Rig {
|
|
|
50
53
|
sibling_write: Probe;
|
|
51
54
|
state_write: Probe;
|
|
52
55
|
staged_write: Probe;
|
|
56
|
+
ssh_key: Probe;
|
|
53
57
|
};
|
|
54
58
|
stagedInput: string;
|
|
55
59
|
siblingFile: string;
|
|
@@ -85,6 +89,20 @@ async function runProbe(): Promise<Rig> {
|
|
|
85
89
|
// produces. This is the path the tmpfs would erase.
|
|
86
90
|
const stagedInput = mkdtempSync(join(tmpdir(), 'celilo-jail-staged-'));
|
|
87
91
|
|
|
92
|
+
// A throwaway SSH key at the exact path the pre-stage-3 jail bound —
|
|
93
|
+
// `homedir()/.ssh` — so the flip (task 5.5) is real: the red baseline
|
|
94
|
+
// binds this directory and the probe reads the key, this branch binds no
|
|
95
|
+
// `.ssh` at all and the read gives ENOENT. Guarded to jailed hosts only
|
|
96
|
+
// (this whole suite is `skipIf(!jailed)`), which on a Mac is never — so
|
|
97
|
+
// the operator's real key is never touched on a dev box; on a jailed CI
|
|
98
|
+
// box homedir is an ephemeral container root. Uniquely named and removed
|
|
99
|
+
// in cleanup regardless.
|
|
100
|
+
const sshDir = join(homedir(), '.ssh');
|
|
101
|
+
const plantedKey = join(sshDir, PLANTED_KEY_NAME);
|
|
102
|
+
const createdSshDir = !existsSync(sshDir);
|
|
103
|
+
mkdirSync(sshDir, { recursive: true });
|
|
104
|
+
writeFileSync(plantedKey, 'not-a-real-key\n');
|
|
105
|
+
|
|
88
106
|
const { logger } = createCapturingLogger();
|
|
89
107
|
const context: HookContext = {
|
|
90
108
|
config: {
|
|
@@ -117,6 +135,8 @@ async function runProbe(): Promise<Rig> {
|
|
|
117
135
|
cleanup: () => {
|
|
118
136
|
rmSync(scratch, { recursive: true, force: true });
|
|
119
137
|
rmSync(stagedInput, { recursive: true, force: true });
|
|
138
|
+
rmSync(plantedKey, { force: true });
|
|
139
|
+
if (createdSshDir) rmSync(sshDir, { recursive: true, force: true });
|
|
120
140
|
},
|
|
121
141
|
};
|
|
122
142
|
}
|
|
@@ -125,7 +145,7 @@ describe.skipIf(!jailed)(`the jail is real (backend: ${availability.backend})`,
|
|
|
125
145
|
test('a hook cannot reach celilo’s master key, or a sibling module', async () => {
|
|
126
146
|
const rig = await runProbe();
|
|
127
147
|
try {
|
|
128
|
-
const { planted_secret, sibling_write, state_write, staged_write } = rig.outputs;
|
|
148
|
+
const { planted_secret, sibling_write, state_write, staged_write, ssh_key } = rig.outputs;
|
|
129
149
|
console.log(['', 'jail probe:', JSON.stringify(rig.outputs, null, 2)].join('\n'));
|
|
130
150
|
|
|
131
151
|
// Unreachability, not an errno. Under bubblewrap the message is ENOENT
|
|
@@ -136,6 +156,12 @@ describe.skipIf(!jailed)(`the jail is real (backend: ${availability.backend})`,
|
|
|
136
156
|
// And the parent's own view: the write did not land by another route.
|
|
137
157
|
expect(existsSync(rig.siblingFile)).toBe(false);
|
|
138
158
|
|
|
159
|
+
// Stage 3 (design D12, task 5.5): the SSH credential is not bound, so
|
|
160
|
+
// a jailed hook cannot authenticate anywhere by hand. This is the
|
|
161
|
+
// recurrence-gate row that FLIPPED when the `~/.ssh` mount was removed
|
|
162
|
+
// — it read `succeeded: true` for the whole of stage 2.
|
|
163
|
+
expect(ssh_key.succeeded).toBe(false);
|
|
164
|
+
|
|
139
165
|
// The carve-out. `state/` is inside the read-only module tree and is
|
|
140
166
|
// bound read-write on top of it; if the ordering were wrong this is the
|
|
141
167
|
// assertion that would catch it.
|
|
@@ -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,
|
|
@@ -178,12 +178,13 @@ describe('the child environment is an allow-list', () => {
|
|
|
178
178
|
// complement: the six things that MUST get through, because a hook with no
|
|
179
179
|
// `PATH` cannot spawn anything and one with no `HOME` breaks more than it
|
|
180
180
|
// protects.
|
|
181
|
-
test('forwards the allow-list and the
|
|
181
|
+
test('forwards the allow-list and the channels, and nothing else', () => {
|
|
182
182
|
process.env.CELILO_TEST_SECRET = 'must-not-cross';
|
|
183
183
|
try {
|
|
184
|
-
const env = hookChildEnv('/tmp/celilo-hook-x/s');
|
|
184
|
+
const env = hookChildEnv('/tmp/celilo-hook-x/s', '/tmp/celilo-hook-x/r', undefined);
|
|
185
185
|
|
|
186
186
|
expect(env.CELILO_HOOK_SOCKET).toBe('/tmp/celilo-hook-x/s');
|
|
187
|
+
expect(env.CELILO_HOOK_REMOTE_SOCKET).toBe('/tmp/celilo-hook-x/r');
|
|
187
188
|
expect(env.CELILO_HOOK_PROTOCOL_VERSION).toBe('1');
|
|
188
189
|
expect(env.PATH).toBe(process.env.PATH as string);
|
|
189
190
|
expect(env.HOME).toBe(process.env.HOME as string);
|
|
@@ -197,9 +198,14 @@ describe('the child environment is an allow-list', () => {
|
|
|
197
198
|
'TMPDIR',
|
|
198
199
|
'CELILO_DEBUG',
|
|
199
200
|
'CELILO_HOOK_SOCKET',
|
|
201
|
+
'CELILO_HOOK_REMOTE_SOCKET',
|
|
200
202
|
'CELILO_HOOK_PROTOCOL_VERSION',
|
|
203
|
+
'CELILO_HOOK_MOUNT_SET',
|
|
201
204
|
]);
|
|
202
205
|
expect(Object.keys(env).filter((name) => !allowed.has(name))).toEqual([]);
|
|
206
|
+
// The unjailed lint's variable is present only when there is something
|
|
207
|
+
// to lint (task 4.7). A jailed run carries nothing.
|
|
208
|
+
expect(env.CELILO_HOOK_MOUNT_SET).toBeUndefined();
|
|
203
209
|
} finally {
|
|
204
210
|
delete process.env.CELILO_TEST_SECRET;
|
|
205
211
|
}
|
|
@@ -211,7 +217,7 @@ describe('the child environment is an allow-list', () => {
|
|
|
211
217
|
try {
|
|
212
218
|
// Absent, not the string "undefined" — which is what a naive copy
|
|
213
219
|
// produces and what a shell then happily uses as a timezone.
|
|
214
|
-
expect('TZ' in hookChildEnv('/tmp/s')).toBe(false);
|
|
220
|
+
expect('TZ' in hookChildEnv('/tmp/s', '/tmp/r', undefined)).toBe(false);
|
|
215
221
|
} finally {
|
|
216
222
|
if (saved !== undefined) process.env.TZ = saved;
|
|
217
223
|
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The jailed browser launch path starts Chromium with `--no-sandbox` (task
|
|
3
|
+
* 4.10 interim, celilo#1215).
|
|
4
|
+
*
|
|
5
|
+
* The behavioural proof lives in `hook-jail-toolchain-reach.test.ts`: with its
|
|
6
|
+
* own sandbox on, Chromium does not start inside the bubblewrap jail; with
|
|
7
|
+
* `--no-sandbox` it renders. But that run needs `CELILO_PROBE_BROWSER`, so on
|
|
8
|
+
* an ordinary host it reports "no browser supplied" and nothing would fail if
|
|
9
|
+
* the flag were dropped from the launcher. This suite is the pin that always
|
|
10
|
+
* runs: it asserts the launch args the probe hook actually uses, and it fails
|
|
11
|
+
* the moment `--no-sandbox` leaves the launch path.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { describe, expect, test } from 'bun:test';
|
|
15
|
+
import { JAILED_LAUNCH_ARGS, SANDBOXED_LAUNCH_ARGS } from './test-fixtures/jail-toolchain-hook';
|
|
16
|
+
|
|
17
|
+
describe('jailed browser launch flags (4.10 interim, celilo#1215)', () => {
|
|
18
|
+
test('the jailed launch path carries --no-sandbox', () => {
|
|
19
|
+
expect(JAILED_LAUNCH_ARGS).toContain('--no-sandbox');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test('the experiment arm keeps Chromium sandboxed, so the comparison stays controlled', () => {
|
|
23
|
+
expect(SANDBOXED_LAUNCH_ARGS).not.toContain('--no-sandbox');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('the two arms differ by the sandbox flag alone', () => {
|
|
27
|
+
const onlyInJailed = JAILED_LAUNCH_ARGS.filter((arg) => !SANDBOXED_LAUNCH_ARGS.includes(arg));
|
|
28
|
+
const onlyInSandboxed = SANDBOXED_LAUNCH_ARGS.filter(
|
|
29
|
+
(arg) => !JAILED_LAUNCH_ARGS.includes(arg),
|
|
30
|
+
);
|
|
31
|
+
expect(onlyInJailed).toEqual(['--no-sandbox']);
|
|
32
|
+
expect(onlyInSandboxed).toEqual([]);
|
|
33
|
+
});
|
|
34
|
+
});
|
package/src/hooks/jail.test.ts
CHANGED
|
@@ -18,6 +18,7 @@ import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node
|
|
|
18
18
|
import { tmpdir } from 'node:os';
|
|
19
19
|
import { join } from 'node:path';
|
|
20
20
|
import {
|
|
21
|
+
JAIL_OFF_REASON,
|
|
21
22
|
type JailAvailability,
|
|
22
23
|
type JailPlan,
|
|
23
24
|
jailModeStorePath,
|
|
@@ -227,6 +228,37 @@ describe('the three policies', () => {
|
|
|
227
228
|
});
|
|
228
229
|
});
|
|
229
230
|
|
|
231
|
+
describe('auto defers on sandbox-exec until D14 exists (ce-29z)', () => {
|
|
232
|
+
// The backend hand-built here is one no probe yields yet — task 4.8 owns
|
|
233
|
+
// the platform probe that reports it. The policy, though, is decided NOW:
|
|
234
|
+
// sandbox-exec denies (EPERM) a hook write to an undeclared host path where
|
|
235
|
+
// bubblewrap masks it into its private tmpfs, so a macOS auto jail reddens
|
|
236
|
+
// the full suite for fixtures main's Linux CI passes. Measured, ce-29z.
|
|
237
|
+
const SANDBOX_EXEC: JailAvailability = { backend: 'sandbox-exec' };
|
|
238
|
+
|
|
239
|
+
test("'auto' runs the hook unjailed and says why", () => {
|
|
240
|
+
const p = planJailedSpawn(CMD, deriveMountSet(REQUEST), SANDBOX_EXEC, 'auto', allPresent);
|
|
241
|
+
expect(p.mode).toBe('unjailed');
|
|
242
|
+
expect(p.cmd).toEqual(CMD);
|
|
243
|
+
expect(p.backend).toBe('sandbox-exec');
|
|
244
|
+
expect(p.reason).toMatch(/D14/);
|
|
245
|
+
expect(p.reason).toMatch(/CELILO_HOOK_JAIL=required/);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test("'required' is the operator's explicit act and is NOT deferred", () => {
|
|
249
|
+
// Mode only, not argv shape: the sandbox-exec spawn arm is task 4.8's.
|
|
250
|
+
// This assertion survives that landing; an argv assertion would not.
|
|
251
|
+
const p = planJailedSpawn(CMD, deriveMountSet(REQUEST), SANDBOX_EXEC, 'required', allPresent);
|
|
252
|
+
expect(p.mode).toBe('jailed');
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
test("'off' still declines a jail that IS available", () => {
|
|
256
|
+
const p = planJailedSpawn(CMD, deriveMountSet(REQUEST), SANDBOX_EXEC, 'off', allPresent);
|
|
257
|
+
expect(p.mode).toBe('unjailed');
|
|
258
|
+
expect(p.reason).toBe(JAIL_OFF_REASON);
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
|
|
230
262
|
describe('jailPolicy reads the operator’s switch', () => {
|
|
231
263
|
const withEnv = <T>(value: string | undefined, fn: () => T): T => {
|
|
232
264
|
const saved = process.env.CELILO_HOOK_JAIL;
|
|
@@ -367,4 +399,64 @@ describe('the mode is recorded state, not a log line (design D8)', () => {
|
|
|
367
399
|
if (saved !== undefined) process.env.CELILO_HOOK_JAIL_MODE_PATH = saved;
|
|
368
400
|
}
|
|
369
401
|
});
|
|
402
|
+
|
|
403
|
+
// The write overwrites the previous record, and the self-monitor (task 4.4)
|
|
404
|
+
// reads the file on a sweep long after `recordJailMode`'s return value is
|
|
405
|
+
// gone. `lastJailed` is what keeps the transition readable.
|
|
406
|
+
test('an unjailed record remembers the jailed record it replaced (task 4.4)', () => {
|
|
407
|
+
withStore(() => {
|
|
408
|
+
recordJailMode(jailed);
|
|
409
|
+
const jailedAt = readJailMode()?.recordedAt;
|
|
410
|
+
recordJailMode(unjailed);
|
|
411
|
+
const record = readJailMode();
|
|
412
|
+
expect(record?.lastJailed?.backend).toBe('bubblewrap');
|
|
413
|
+
expect(record?.lastJailed?.recordedAt).toBe(jailedAt as string);
|
|
414
|
+
});
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
test('re-jailing clears the memory, so a later regression dates from the new jailed record', () => {
|
|
418
|
+
withStore(() => {
|
|
419
|
+
recordJailMode(jailed);
|
|
420
|
+
recordJailMode(unjailed);
|
|
421
|
+
recordJailMode(jailed);
|
|
422
|
+
expect(readJailMode()?.lastJailed).toBeUndefined();
|
|
423
|
+
});
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
test('an unjailed-to-unjailed rewrite carries the memory along', () => {
|
|
427
|
+
withStore(() => {
|
|
428
|
+
// CELILO_HOOK_JAIL=off on a host whose backend still works: mode
|
|
429
|
+
// unjailed, backend bubblewrap. The regression memory must survive the
|
|
430
|
+
// operator then losing the backend too.
|
|
431
|
+
const off: JailPlan = {
|
|
432
|
+
cmd: CMD,
|
|
433
|
+
mode: 'unjailed',
|
|
434
|
+
backend: 'bubblewrap',
|
|
435
|
+
reason: 'CELILO_HOOK_JAIL=off',
|
|
436
|
+
skipped: [],
|
|
437
|
+
};
|
|
438
|
+
recordJailMode(jailed);
|
|
439
|
+
const jailedAt = readJailMode()?.recordedAt;
|
|
440
|
+
recordJailMode(off);
|
|
441
|
+
recordJailMode(unjailed);
|
|
442
|
+
expect(readJailMode()?.lastJailed?.recordedAt).toBe(jailedAt as string);
|
|
443
|
+
});
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
test("another host's jailed record is a move, not a transition", () => {
|
|
447
|
+
withStore((path) => {
|
|
448
|
+
mkdirSync(join(path, '..'), { recursive: true });
|
|
449
|
+
writeFileSync(
|
|
450
|
+
path,
|
|
451
|
+
JSON.stringify({
|
|
452
|
+
mode: 'jailed',
|
|
453
|
+
backend: 'bubblewrap',
|
|
454
|
+
host: 'some-other-box',
|
|
455
|
+
recordedAt: '2026-01-01T00:00:00.000Z',
|
|
456
|
+
}),
|
|
457
|
+
);
|
|
458
|
+
recordJailMode(unjailed);
|
|
459
|
+
expect(readJailMode()?.lastJailed).toBeUndefined();
|
|
460
|
+
});
|
|
461
|
+
});
|
|
370
462
|
});
|