@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.
@@ -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, self-registers). **provides:** `celilo_event_bus`, `celilo_module_deploy_worker`. **requires:** `cross_module_read`. See `openspec/specs/management-as-module/spec.md`.
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/`.
@@ -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.0.0",
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.0.0",
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
@@ -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
- 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 }) };
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
- // 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);
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
- // `~/.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);
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
- expect(reach.bwrap_present.succeeded).toBe(false);
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
- const jailed = availability.backend !== 'none';
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
- const outputs = (await executeHookScript(PROBE_HOOK, context, {
123
- timeoutMs: 60_000,
124
- idleTimeoutMs: 60_000,
125
- jail: {
126
- modulePath,
127
- pathInputs: [{ name: 'staged_input', value: stagedInput, access: 'write' }],
128
- },
129
- })) as unknown as Rig['outputs'];
130
-
131
- return {
132
- outputs,
133
- stagedInput,
134
- siblingFile,
135
- cleanup: () => {
136
- rmSync(scratch, { recursive: true, force: true });
137
- rmSync(stagedInput, { recursive: true, force: true });
138
- rmSync(plantedKey, { force: true });
139
- if (createdSshDir) rmSync(sshDir, { recursive: true, force: true });
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)('no jail on this host', () => {
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 a Mac reading as "the jail was proven".
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 — ${availability.reason ?? 'no backend'}\nThese properties are proven on Linux with bubblewrap. The hermetic half runs everywhere (jail.test.ts).`,
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
- expect(availability.backend).toBe('none');
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
- const outputs = await executeHookScript(TRESPASS_SCRIPT, context, {
167
- timeoutMs: 60_000,
168
- idleTimeoutMs: 60_000,
169
- });
170
- return {
171
- report: outputs as unknown as TrespassReport,
172
- lines: messages.map((m) => m.message),
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
+ );