@celilo/cli 2.0.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 +1 -1
- package/CELILO_SUBSYSTEMS.md +1 -0
- package/package.json +2 -2
- package/src/hooks/capability-loader.ts +1 -2
- package/src/hooks/executor.ts +6 -0
- package/src/hooks/hook-jail-toolchain-reach.test.ts +76 -27
- package/src/hooks/hook-jail-unreachability.test.ts +51 -26
- package/src/hooks/hook-trespass.test.ts +65 -9
- package/src/hooks/jail.ts +180 -25
- package/src/hooks/mount-set.test.ts +88 -1
- package/src/hooks/mount-set.ts +155 -0
- package/src/hooks/unjailed-lint.test.ts +5 -2
- package/src/services/alerting/coverage-source.test.ts +86 -0
- package/src/services/alerting/coverage-source.ts +11 -1
- 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/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 +34 -3
- package/src/services/module-deploy.ts +51 -0
- package/src/services/remote-access.test.ts +88 -4
- package/src/services/remote-access.ts +52 -1
package/CELILO_CORE_MODULES.md
CHANGED
|
@@ -54,7 +54,7 @@ Each entry: `module id` — what it is — **provides** / **requires** capabilit
|
|
|
54
54
|
|
|
55
55
|
## Celilo's own infrastructure (self-hosted)
|
|
56
56
|
|
|
57
|
-
- **celilo-mgmt** — the celilo management server itself, deployed as a module (replaces install.sh + `system init`; ships daemon, runs migrations,
|
|
57
|
+
- **celilo-mgmt** — the celilo management server itself, deployed as a module (replaces install.sh + `system init`; ships daemon, runs migrations). Has **no `on_install`** — celilo initialises the box itself during the deploy (`bootstrapControlPlane`), because a jailed hook cannot spawn the CLI it used to reach (celilo#1225). **provides:** `celilo_event_bus`, `celilo_module_deploy_worker`. **requires:** `cross_module_read`. See `openspec/specs/management-as-module/spec.md`.
|
|
58
58
|
- **celilo-registry** — module registry server (Cargo sparse protocol); stores `.netapp` files, serves index + search/download API. On install it provisions a confidential introspection OIDC client via `idp.create_oidc_client` (SECURE_MODULE_PUBLISH.md §5[D-A]) and converges its issuer + introspection endpoint + creds onto the box for RFC 7662 token verification. Its `sweep_revisions` hook is the store's ONLY delete path — `yank` flips a boolean in the index entry and frees nothing, so before it existed every build revision ever published was retained forever and a `+N` revision is auto-assigned on every publish. The production store reached 18 GB of which ~93% was superseded history, filled a 20 GB disk and took the release pipeline down. The sweep keeps the newest `sweep_keep_build_revisions` (default 1) of EVERY release, so no release can disappear and a manual rollback still works — that is what makes the hourly `timer.tick.1h` subscription defensible. It removes the index line BEFORE the payload, because the download route reads the payload file directly and never consults the index: that order leaves an interrupted sweep with an unlisted-but-served version rather than a listed one that 404s, and the orphan it leaves is reclaimed by the next run. On demand with `celilo module run-hook celilo-registry sweep_revisions` (add `dry_run=true` to see the plan). **provides:** `registry_publish`. **requires:** `public_web`, `dns_registrar`, `idp`.
|
|
59
59
|
- **celilo-apt-repo** — Debian apt repository (reprepro + Bun HTTP server) serving the celilo `.deb` at apt.celilo.computer. **provides:** `apt_publish`. **requires:** `public_web`, `dns_registrar`.
|
|
60
60
|
- **signal** — bidirectional Signal transport for alerts and deploy-interview questions; runs signal-cli in daemon mode with its JSON-RPC socket bound to the host's own address (never public) and **`--receive-mode=manual`**, which is load-bearing: signal-cli's default (`on-start`) leaves the daemon permanently receiving, so it drains every reply into an SSE stream nothing is attached to and REFUSES celilo's `receive` call — replies arrive and are unreadable, while `send` and every health check keep passing. Enrolled as a SECONDARY DEVICE of an existing Signal account rather than registering its own number — Signal blocks most VOIP ranges and bans bot-ish registrations. Recipient addresses live on celilo routes, not in module config, so adding a person never requires a redeploy. Runs on x86_64 and aarch64. `libsignal-client` ships no linux-aarch64 native, so celilo builds one (`modules/signal/build/`) and installs it as a `libsignal-jni` .deb on ARM hosts; x86_64 uses the JAR's bundled native. **provides:** `notification` (`send`, `receive`). **requires:** no capabilities — a transport that depended on the proxy, registrar or firewall could not tell you those were broken — and a system in the **`secure-mgmt`** zone: it holds a linked Signal account (the operator's own messaging identity and keys), and its job is to observe every tier while depending on none, which is what the control-plane zone is for. See `openspec/changes/add-alerting/`.
|
package/CELILO_SUBSYSTEMS.md
CHANGED
|
@@ -205,6 +205,7 @@ runner seam (`execRunner` real / `createMockRunner` for tests) lives in
|
|
|
205
205
|
- **Remote-ops broker (reachability scoped by the credential, stage 3 / D12)** — `apps/celilo/src/hooks/remote-broker.ts` (`startRemoteBroker`, `RemoteAccessPolicy`) answering a SECOND socket beside the capability one, with the policy in `apps/celilo/src/services/remote-access.ts` (`remoteAccessPolicy`) and the asking half inside `@celilo/capabilities`' own remote primitives (`packages/capabilities/src/remote.ts`, "The hook remote-ops bridge"). The jail binds no `~/.ssh`, so a hand-built `ssh` cannot authenticate; the primitives detect `CELILO_HOOK_REMOTE_SOCKET`, send each operation as a STRUCTURED request (never a shell string — the broker rebuilds the ssh line itself), and the broker checks the target against `ownedSystemModuleIds` + `getModuleSystems` before running anything, refusing with the module, the target, and the capability route named. Requests carrying the module's OWN credential (an `identityFile` crossing as content and materialised per call, or `installAuthorizedKey`'s password — the cPanel case) are scoped by that credential instead; an explicit non-root user likewise, because the fleet key's authority is root on fleet systems. Stream primitives' LOCAL paths are confined to the run's granted roots (stateDir, screenshots, generated/, declared path inputs), or `streamBackup` would be a write-as-celilo oracle. Attribution is by the module that PERFORMS the operation: a hook's request to the hook's module (the nine `invokeHook` sites build the policy), a provider's transport to the provider's module — providers run in-process and do not cross this socket, and `public_web`'s hand-built upload was replaced by the Ansible static-content converge (capability-owned-tables stage 4, celilo#1014). Residual, recorded rather than papered over: a hook can still `fetch()` any HTTP endpoint directly; only `probeHttp` consults the target check.
|
|
206
206
|
- **Hook jail (a hook sees the paths it was given, and nothing else)** — `apps/celilo/src/hooks/mount-set.ts` (`deriveMountSet`, `toBwrapArgs`, `forbiddenPaths` — PURE, computes a filesystem view and touches nothing) and `apps/celilo/src/hooks/jail.ts` (`detectJailBackend`, `planJailedSpawn`, `realpathRequest`, `runtimeModulePathsFor`, `recordJailMode` — the half that touches the machine). `executeHookScript` spawns the shim under `bwrap` with the module's tree read-only, `state/` + `generated/` + this run's `screenshots/<run>` read-write on top of it, each contract-declared path input at its declared access, the broker's socket directory, the runtime and the `node_modules` the shim resolves through, and `/tmp` a fresh tmpfs FIRST so it cannot erase the socket or a staged input. `~/.ssh` is deliberately NOT bound (stage 3, D12): withholding the credential is what makes the remote-ops broker's target check a boundary. **The acceptance criterion is absence, not a check**: the module store and the data directory are simply not bound, so `master.key` and a sibling module give `ENOENT`. The set is DERIVED — a module cannot ask for more — and `bwrap` itself is never in it, because the AppArmor profile grants `userns` to `/usr/bin/bwrap` for anyone on the box (design D9). Backend detection RUNS bubblewrap rather than looking for it (four different denials all leave the binary in place), and the resulting mode is written to `hook-jail-mode.json` beside celilo's other per-machine state rather than logged, so a host that stops jailing is readable. `CELILO_HOOK_JAIL` = `auto` (default) / `required` / `off`. macOS has a `sandbox-exec` backend in the union, but `auto` defers on it (ce-29z: sandbox-exec DENIES undeclared writes where bubblewrap masks them, and D14's declared-path mechanism is not built), so the hook runs unjailed with the mode recorded; `required` bypasses the deferral for an operator who opts in. An `unjailed` record carries `lastJailed` (the jailed record it replaced on the same host), which is what the `hook_jail` self-monitor reads (`services/alerting/hook-jail.ts`, created unsuppressible by `celilo monitor add hook_jail`) to alert on a host that used to jail and has stopped. `celilo system doctor`'s "Hook execution" section (`renderHookExecutionSection`) reports the live mode and, when unjailed, why.
|
|
207
207
|
- **Deploy pipeline** — `apps/celilo/src/services/module-deploy.ts`.
|
|
208
|
+
- **Control-plane bootstrap (celilo initialises its own box)** — `apps/celilo/src/services/control-plane-bootstrap.ts` (`bootstrapControlPlane`) with `apps/celilo/src/services/dns-discovery.ts` (`discoverDns`) beside `network-discovery.ts`. Called from `module-deploy.ts` at the point `on_install` runs, for `CONTROL_PLANE_MODULE_ID` (exported from `services/deployed-systems.ts`, replacing three private copies of the string). Reads the box's upstream resolvers, mints the fleet key via `ensureFleetKey`, writes `dns.*` + `ssh.public_key` in ONE `initializeSystem` call, records the network via `discoverAndRecordNetwork`, then polls `checkDispatcher` and FAILS the deploy if no dispatcher answers. **It is not a hook, and that is the point** (celilo#1225): it was `modules/celilo-mgmt/scripts/on_install.ts`, which reached all of this by spawning the `celilo` CLI — impossible inside the jail, whose mount set binds no `/usr/bin`, no `/usr/local/bin` and no shell, so the deploy ran Ansible clean and then died in its own install hook on every host with a jail backend. celilo-mgmt is never deployed to a remote box, so the host being configured is always the host celilo runs on and the hook was a process boundary with celilo on both sides. Self-registering the management host into its own pool did NOT move: celilo places a module only on a machine already in the pool. The dispatcher read is celilo's four-part `checkDispatcher`, not the "is a process up?" probe the hook used.
|
|
208
209
|
- **DNS provider backfill** (re-emit registrations when a provider deploys) — `apps/celilo/src/services/dns-provider-backfill.ts` — `isDnsInternalProvider`, `backfillProviderDns`.
|
|
209
210
|
- **Base-module aspects (fan-out across the fleet)** — `apps/celilo/src/services/aspect-runner.ts` — `planAspectFanOut`, `runAspectFanOut`, `maybeRunAspectForTrigger`. Aspect content lives in `modules/<m>/base-module-aspect/` (e.g. knot-unbound-internal, technitium). Two directions, with DELIBERATELY OPPOSITE failure semantics:
|
|
210
211
|
- **Outbound** (`maybeRunAspectForTrigger`, `on_install`): one provider's aspect across the whole fleet, enumerated at that instant. A failure never fails the provider's own deploy — aspects are forward-progress and a partial fleet converges on the next fan-out.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@celilo/cli",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.1.0",
|
|
4
4
|
"description": "Celilo — home lab orchestration CLI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -58,7 +58,7 @@
|
|
|
58
58
|
"dependencies": {
|
|
59
59
|
"@aws-sdk/client-s3": "^3.1109.0",
|
|
60
60
|
"@aws-sdk/lib-storage": "^3.1101.0",
|
|
61
|
-
"@celilo/capabilities": "^4.
|
|
61
|
+
"@celilo/capabilities": "^4.1.0",
|
|
62
62
|
"@celilo/cli-display": "^0.2.0",
|
|
63
63
|
"@celilo/core": "^0.11.0",
|
|
64
64
|
"@celilo/event-bus": "^0.6.0",
|
|
@@ -45,7 +45,7 @@ import { getOrCreateMasterKey } from '../secrets/master-key';
|
|
|
45
45
|
import { buildControlPlaneApi } from '../services/api-principal-enrolment';
|
|
46
46
|
import { recordCapabilityBinding, withBindingRecord } from '../services/capability-bindings';
|
|
47
47
|
import { emitWebRoutesChangedAndWait } from '../services/celilo-events';
|
|
48
|
-
import { getModuleSystems } from '../services/deployed-systems';
|
|
48
|
+
import { CONTROL_PLANE_MODULE_ID, getModuleSystems } from '../services/deployed-systems';
|
|
49
49
|
import { withDnsInternalLedger } from '../services/dns-internal-records';
|
|
50
50
|
import { withDnsRegistrationLedger } from '../services/dns-registrations';
|
|
51
51
|
import { buildPortForwardStore } from '../services/port-forwards';
|
|
@@ -952,7 +952,6 @@ interface FirewallZones {
|
|
|
952
952
|
}
|
|
953
953
|
|
|
954
954
|
/** The module that IS celilo's control plane; its network is what we trust. */
|
|
955
|
-
const CONTROL_PLANE_MODULE_ID = 'celilo-mgmt';
|
|
956
955
|
|
|
957
956
|
function readZoneSubnet(db: DbClient, zone: string): string | undefined {
|
|
958
957
|
const row = db
|
package/src/hooks/executor.ts
CHANGED
|
@@ -367,6 +367,12 @@ export async function executeHookScript(
|
|
|
367
367
|
|
|
368
368
|
const child = Bun.spawn({
|
|
369
369
|
cmd: [...jail.cmd],
|
|
370
|
+
// `sandbox-exec` has no `--chdir`, so the plan names one and the spawn
|
|
371
|
+
// sets it. Without it the child inherits the operator's shell directory,
|
|
372
|
+
// which the profile does not name — and `bun` reads its cwd before it
|
|
373
|
+
// runs anything, so the hook dies with a message about nothing in
|
|
374
|
+
// particular. bubblewrap plans leave this absent and use `--chdir`.
|
|
375
|
+
...(jail.cwd ? { cwd: jail.cwd } : {}),
|
|
370
376
|
env: hookChildEnv(
|
|
371
377
|
broker.socketPath,
|
|
372
378
|
remoteBroker.socketPath,
|
|
@@ -125,22 +125,38 @@ async function measureReach(): Promise<{ reach: Reach; cleanup: () => void }> {
|
|
|
125
125
|
capabilities: {},
|
|
126
126
|
};
|
|
127
127
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
+
}
|
|
144
160
|
}
|
|
145
161
|
|
|
146
162
|
describe.skipIf(!jailed)(`what a jailed hook can run (backend: ${availability.backend})`, () => {
|
|
@@ -157,18 +173,46 @@ describe.skipIf(!jailed)(`what a jailed hook can run (backend: ${availability.ba
|
|
|
157
173
|
console.error(` ${probe.succeeded ? 'REACHED' : 'ABSENT '} ${name}: ${probe.detail}`);
|
|
158
174
|
}
|
|
159
175
|
|
|
160
|
-
// ── The finding
|
|
161
|
-
//
|
|
162
|
-
//
|
|
163
|
-
|
|
164
|
-
|
|
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);
|
|
165
207
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
+
}
|
|
170
213
|
|
|
171
|
-
// Nothing binds the resolver's configuration
|
|
214
|
+
// Nothing binds the resolver's configuration, on either platform:
|
|
215
|
+
// bubblewrap by absence, sandbox-exec by `deny default` (EPERM).
|
|
172
216
|
expect(reach.resolv_conf.succeeded).toBe(false);
|
|
173
217
|
|
|
174
218
|
// ── Task 4.10 ──────────────────────────────────────────────────────
|
|
@@ -209,7 +253,12 @@ describe.skipIf(!jailed)(`what a jailed hook can run (backend: ${availability.ba
|
|
|
209
253
|
// edit that would flip it: someone reads the failures above, binds
|
|
210
254
|
// `/usr/bin` to fix them, and brings `bwrap` in with it. `isForbidden`
|
|
211
255
|
// compares whole paths, so a bind of the DIRECTORY passes that filter.
|
|
212
|
-
|
|
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
|
+
}
|
|
213
262
|
} finally {
|
|
214
263
|
cleanup();
|
|
215
264
|
}
|
|
@@ -33,7 +33,7 @@ import { join, resolve } from 'node:path';
|
|
|
33
33
|
/** Uniquely named so it never collides with a real key on a jailed dev box. */
|
|
34
34
|
const PLANTED_KEY_NAME = 'id_celilo_jail_probe';
|
|
35
35
|
import { executeHookScript } from './executor';
|
|
36
|
-
import { detectJailBackend } from './jail';
|
|
36
|
+
import { detectJailBackend, jailPolicy } from './jail';
|
|
37
37
|
import { createCapturingLogger } from './logger';
|
|
38
38
|
import type { HookContext } from './types';
|
|
39
39
|
|
|
@@ -45,7 +45,12 @@ interface Probe {
|
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
const availability = detectJailBackend();
|
|
48
|
-
|
|
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';
|
|
49
54
|
|
|
50
55
|
interface Rig {
|
|
51
56
|
outputs: {
|
|
@@ -119,26 +124,38 @@ async function runProbe(): Promise<Rig> {
|
|
|
119
124
|
capabilities: {},
|
|
120
125
|
};
|
|
121
126
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
+
}
|
|
142
159
|
}
|
|
143
160
|
|
|
144
161
|
describe.skipIf(!jailed)(`the jail is real (backend: ${availability.backend})`, () => {
|
|
@@ -187,13 +204,21 @@ describe.skipIf(!jailed)(`the jail is real (backend: ${availability.backend})`,
|
|
|
187
204
|
}, 90_000);
|
|
188
205
|
});
|
|
189
206
|
|
|
190
|
-
describe.skipIf(jailed)('
|
|
207
|
+
describe.skipIf(jailed)('this run is not jailed', () => {
|
|
191
208
|
test('says so, rather than reporting a pass it did not earn', () => {
|
|
192
209
|
// Not an assertion about the product. It is the line that stops a green
|
|
193
|
-
// 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`;
|
|
194
215
|
console.log(
|
|
195
|
-
`\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).`,
|
|
196
217
|
);
|
|
197
|
-
|
|
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);
|
|
198
223
|
});
|
|
199
224
|
});
|
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
planFallbacks,
|
|
33
33
|
} from '../../../../scripts/workspace-fallback';
|
|
34
34
|
import { executeHookScript, hookChildEnv } from './executor';
|
|
35
|
+
import { detectJailBackend, jailPolicy } from './jail';
|
|
35
36
|
import { createCapturingLogger } from './logger';
|
|
36
37
|
import type { HookContext } from './types';
|
|
37
38
|
|
|
@@ -150,7 +151,7 @@ afterAll(() => {
|
|
|
150
151
|
rmSync(scratchHome, { recursive: true, force: true });
|
|
151
152
|
});
|
|
152
153
|
|
|
153
|
-
async function runTrespass(): Promise<{ report: TrespassReport; lines: string[] }> {
|
|
154
|
+
async function runTrespass(jail = false): Promise<{ report: TrespassReport; lines: string[] }> {
|
|
154
155
|
const { logger, messages } = createCapturingLogger();
|
|
155
156
|
const context: HookContext = {
|
|
156
157
|
config: { sibling_module_id: 'hello-foo', other_system_ip: '' },
|
|
@@ -163,14 +164,28 @@ async function runTrespass(): Promise<{ report: TrespassReport; lines: string[]
|
|
|
163
164
|
capabilities: {},
|
|
164
165
|
};
|
|
165
166
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
167
|
+
// The jailed run jails under `required`, not the `auto` default. ce-29z
|
|
168
|
+
// made `auto` defer on sandbox-exec until D14 exists (undeclared host-path
|
|
169
|
+
// writes would break module hooks), so the test's jail is the operator's
|
|
170
|
+
// explicit act — exactly the bypass the deferral leaves open.
|
|
171
|
+
const savedPolicy = process.env.CELILO_HOOK_JAIL;
|
|
172
|
+
if (jail) process.env.CELILO_HOOK_JAIL = 'required';
|
|
173
|
+
try {
|
|
174
|
+
const outputs = await executeHookScript(TRESPASS_SCRIPT, context, {
|
|
175
|
+
timeoutMs: 60_000,
|
|
176
|
+
idleTimeoutMs: 60_000,
|
|
177
|
+
// The module's own tree, two levels above the hook script. Absent means
|
|
178
|
+
// "run unjailed", which is what the stage 1 assertions below need.
|
|
179
|
+
...(jail ? { jail: { modulePath: dirname(dirname(TRESPASS_SCRIPT)), pathInputs: [] } } : {}),
|
|
180
|
+
});
|
|
181
|
+
return {
|
|
182
|
+
report: outputs as unknown as TrespassReport,
|
|
183
|
+
lines: messages.map((m) => m.message),
|
|
184
|
+
};
|
|
185
|
+
} finally {
|
|
186
|
+
if (savedPolicy === undefined) delete process.env.CELILO_HOOK_JAIL;
|
|
187
|
+
else process.env.CELILO_HOOK_JAIL = savedPolicy;
|
|
188
|
+
}
|
|
174
189
|
}
|
|
175
190
|
|
|
176
191
|
describe('the child environment is an allow-list', () => {
|
|
@@ -251,3 +266,44 @@ describe('hook process boundary — hello-trespass gate', () => {
|
|
|
251
266
|
expect(report.ssh_key.attempted && !report.ssh_key.succeeded).toBe(false);
|
|
252
267
|
});
|
|
253
268
|
});
|
|
269
|
+
|
|
270
|
+
const availability = detectJailBackend();
|
|
271
|
+
const jailed = availability.backend !== 'none' && jailPolicy() !== 'off';
|
|
272
|
+
|
|
273
|
+
describe.skipIf(!jailed)(
|
|
274
|
+
`stage 2: the same hook, jailed (backend: ${availability.backend})`,
|
|
275
|
+
() => {
|
|
276
|
+
// Task 4.12's platform half that a Mac can run. The e2e stage
|
|
277
|
+
// (`e2e/tests/hook-jail-trespass.test.ts`) is the other one, because
|
|
278
|
+
// bubblewrap needs a Linux kernel and a container to hold it.
|
|
279
|
+
//
|
|
280
|
+
// The SAME fixture, run twice, is the whole design of this gate: the stage 1
|
|
281
|
+
// block above asserts these two trespasses SUCCEED, and this one asserts the
|
|
282
|
+
// jail refuses them. Neither reading is available from one run.
|
|
283
|
+
test('trespasses 1 and 2 are refused, and the environment is still clean', async () => {
|
|
284
|
+
const { report, lines } = await runTrespass(true);
|
|
285
|
+
console.log(['', 'hello-trespass (jailed):', ...lines.slice(1)].join('\n'));
|
|
286
|
+
|
|
287
|
+
// What stage 2 claims: celilo's data directory is not bound, so the master
|
|
288
|
+
// key is unreachable. `<store>` is not bound either, so a sibling is too.
|
|
289
|
+
expect(report.master_key.succeeded).toBe(false);
|
|
290
|
+
expect(report.sibling_write.succeeded).toBe(false);
|
|
291
|
+
|
|
292
|
+
// Unreachability, never an errno. bubblewrap removes the path and reports
|
|
293
|
+
// ENOENT; sandbox-exec denies it and reports EPERM. Pinning either one
|
|
294
|
+
// would go red on a platform whose jail works perfectly (D9).
|
|
295
|
+
expect(report.master_key.detail).toMatch(/ENOENT|EPERM/);
|
|
296
|
+
|
|
297
|
+
// Stage 1's claim has not regressed on the way to stage 2.
|
|
298
|
+
expect(report.sensitive_env).toEqual([]);
|
|
299
|
+
}, 120_000);
|
|
300
|
+
|
|
301
|
+
// Trespass 3 is deliberately NOT asserted here, and the reason is a property
|
|
302
|
+
// of this fixture rather than of the jail. `jailRequest` binds `~/.ssh` from
|
|
303
|
+
// `os.homedir()`, which on macOS reads the password database and ignores the
|
|
304
|
+
// `HOME` this file redirects — so the hook looks in the scratch home and the
|
|
305
|
+
// jail bound the real one, and the row reports a refusal that says nothing
|
|
306
|
+
// about stage 3. The e2e stage asserts it, where the two agree. Filed as
|
|
307
|
+
// celilo#1211.
|
|
308
|
+
},
|
|
309
|
+
);
|