@celilo/cli 2.0.0 → 2.2.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/drizzle/0031_module_config_source.sql +20 -0
- package/drizzle/meta/_journal.json +8 -1
- package/package.json +2 -2
- package/schemas/system_config.json +2 -1
- package/src/capabilities/public-web-publish.test.ts +61 -0
- package/src/cli/commands/firewall-interface-list.test.ts +156 -7
- package/src/cli/commands/firewall-interface-list.ts +73 -7
- package/src/cli/commands/machine-add.ts +12 -55
- package/src/cli/commands/module-config.test.ts +20 -1
- package/src/cli/commands/module-import.ts +1 -1
- package/src/cli/commands/module-update.test.ts +82 -0
- package/src/cli/commands/module-update.ts +14 -4
- package/src/cli/commands/monitor.ts +2 -10
- package/src/cli/commands/restore.ts +16 -6
- package/src/cli/generate-zsh-completion.ts +1 -1
- package/src/cli/index.ts +4 -3
- package/src/cli/restore-migration-failure.test.ts +159 -0
- package/src/db/client.ts +5 -0
- package/src/db/migrate.test.ts +61 -135
- package/src/db/migrate.ts +7 -2
- package/src/db/schema.ts +10 -0
- package/src/hooks/broker.test.ts +106 -2
- package/src/hooks/broker.ts +91 -1
- package/src/hooks/capability-loader-firewall.test.ts +37 -0
- package/src/hooks/capability-loader.ts +16 -3
- package/src/hooks/define-hook.test.ts +4 -3
- package/src/hooks/executor.test.ts +19 -18
- package/src/hooks/executor.ts +88 -11
- package/src/hooks/hook-jail-toolchain-reach.test.ts +79 -29
- package/src/hooks/hook-jail-unreachability.test.ts +55 -29
- package/src/hooks/hook-protocol.ts +46 -1
- package/src/hooks/hook-runner.ts +36 -0
- package/src/hooks/hook-store-proxy.test.ts +109 -0
- package/src/hooks/hook-store-proxy.ts +85 -0
- package/src/hooks/hook-store.test.ts +162 -0
- package/src/hooks/hook-store.ts +290 -0
- package/src/hooks/hook-timeout.test.ts +3 -2
- package/src/hooks/hook-trespass.test.ts +94 -14
- package/src/hooks/jail.test.ts +1 -1
- package/src/hooks/jail.ts +194 -32
- package/src/hooks/mount-set.test.ts +296 -1
- package/src/hooks/mount-set.ts +216 -13
- package/src/hooks/run-named-hook.ts +2 -0
- package/src/hooks/test-fixtures/jail-probe-hook.ts +1 -1
- package/src/hooks/test-fixtures/on-restore-staging-hook.ts +26 -0
- package/src/hooks/test-fixtures/store-backed.ts +47 -0
- package/src/hooks/test-fixtures/store-writing-hook.ts +63 -0
- package/src/hooks/unjailed-lint.test.ts +27 -8
- package/src/manifest/schema.ts +1 -0
- package/src/module/packaging/build.ts +70 -2
- package/src/module/web-root.ts +17 -1
- package/src/policy/fixture-capability-coverage.test.ts +322 -0
- package/src/policy/module-script-scan.test.ts +42 -1
- package/src/policy/module-script-scan.ts +275 -5
- package/src/policy/no-hand-built-ssh.test.ts +34 -1
- package/src/policy/no-swallowed-refusal.test.ts +265 -0
- package/src/policy/no-tar-shell-out-in-services.test.ts +43 -0
- package/src/registry/client.test.ts +149 -0
- package/src/registry/client.ts +203 -11
- package/src/services/alerting/coverage-source.test.ts +86 -0
- package/src/services/alerting/coverage-source.ts +11 -1
- package/src/services/alerting/format.test.ts +57 -0
- package/src/services/alerting/format.ts +24 -0
- package/src/services/alerting/run-monitor.ts +2 -2
- package/src/services/backup-create.ts +7 -7
- package/src/services/backup-envelope-roundtrip.test.ts +45 -2
- package/src/services/backup-restore.ts +8 -4
- package/src/services/bus-interview.ts +37 -14
- package/src/services/config-provenance.ts +4 -0
- package/src/services/control-plane-bootstrap.test.ts +297 -0
- package/src/services/control-plane-bootstrap.ts +223 -0
- package/src/services/control-plane-health.test.ts +66 -0
- package/src/services/control-plane-health.ts +67 -0
- package/src/services/deploy-preflight.ts +8 -2
- package/src/services/deploy-validation.test.ts +22 -0
- package/src/services/deploy-validation.ts +8 -0
- package/src/services/deployed-systems.ts +12 -0
- package/src/services/dns-discovery.test.ts +147 -0
- package/src/services/dns-discovery.ts +134 -0
- package/src/services/fleet-checks.ts +6 -2
- package/src/services/fleet-key.test.ts +66 -2
- package/src/services/fleet-key.ts +54 -0
- package/src/services/health-runner.ts +36 -3
- package/src/services/module-config.ts +20 -2
- package/src/services/module-deploy.dns-repoint.test.ts +187 -0
- package/src/services/module-deploy.ts +214 -1
- package/src/services/module-validator/git-hygiene.test.ts +122 -3
- package/src/services/module-validator/git-hygiene.ts +83 -14
- package/src/services/remote-access.test.ts +88 -4
- package/src/services/remote-access.ts +52 -1
- package/src/services/restore-from-file.test.ts +20 -0
- package/src/services/restore-from-file.ts +21 -6
- package/src/services/static-content-converge.test.ts +140 -2
- package/src/services/static-content-converge.ts +55 -8
- package/src/services/system-config-schema-types.ts +1 -1
- package/src/services/system-config-validator.test.ts +36 -0
- package/src/services/system-config-validator.ts +11 -0
- package/src/services/trusted-sources.test.ts +30 -0
- package/src/services/trusted-sources.ts +47 -10
- package/src/templates/generator.ts +9 -2
- package/src/variables/context.ts +16 -5
package/src/hooks/executor.ts
CHANGED
|
@@ -60,6 +60,7 @@ import {
|
|
|
60
60
|
import { isPrivilegedCapability } from '../manifest/validate';
|
|
61
61
|
import { pruneModuleArtifacts } from './artifact-retention';
|
|
62
62
|
import { startBroker } from './broker';
|
|
63
|
+
import type { HookStoresProvider } from './broker';
|
|
63
64
|
import {
|
|
64
65
|
HOOK_MOUNT_SET_ENV,
|
|
65
66
|
HOOK_PROTOCOL_VERSION,
|
|
@@ -169,6 +170,35 @@ const FORWARDED_ENV = [
|
|
|
169
170
|
'no_proxy',
|
|
170
171
|
] as const;
|
|
171
172
|
|
|
173
|
+
/**
|
|
174
|
+
* The PATH a hook child gets: the CLI's own directory ahead of the inherited
|
|
175
|
+
* one, so the child can exec the SAME `celilo` the parent runs.
|
|
176
|
+
*
|
|
177
|
+
* A bun global install lands the `celilo` bin beside the bun binary itself
|
|
178
|
+
* (install.sh sets `BUN_INSTALL/bin`, default `~/.bun/bin`), so
|
|
179
|
+
* `dirname(process.execPath)` is that directory. The inherited PATH carries no
|
|
180
|
+
* such guarantee: the e2e management image runs the CLI only through an
|
|
181
|
+
* absolute-path wrapper at /usr/local/bin because its ENV has no /root/.bun/bin,
|
|
182
|
+
* and every hook that spawned `celilo` by name — caddy-internal's
|
|
183
|
+
* `on_consumer_removed` route withdrawal — died with "Executable not found in
|
|
184
|
+
* $PATH", leaving the stale routes it was withdrawing in place (celilo#1300).
|
|
185
|
+
* A real fleet hits the same class whenever the CLI is launched by a systemd
|
|
186
|
+
* unit or an absolute path with a minimal PATH.
|
|
187
|
+
*
|
|
188
|
+
* Already-present wins: an operator who has the directory on PATH sees an
|
|
189
|
+
* unchanged value. An absent inherited PATH still yields the CLI directory,
|
|
190
|
+
* so a hook can always reach `celilo` even from a stripped parent.
|
|
191
|
+
*
|
|
192
|
+
* Exported for the allow-list tests (hook-trespass.test.ts), which pin this
|
|
193
|
+
* contract the same way they pin the rest of the child environment.
|
|
194
|
+
*/
|
|
195
|
+
export function childPath(inherited: string | undefined): string {
|
|
196
|
+
const cliDir = dirname(process.execPath);
|
|
197
|
+
if (inherited === undefined) return cliDir;
|
|
198
|
+
if (inherited.split(':').includes(cliDir)) return inherited;
|
|
199
|
+
return `${cliDir}:${inherited}`;
|
|
200
|
+
}
|
|
201
|
+
|
|
172
202
|
/** The shim celilo spawns. Resolved from here so an npm install finds it too. */
|
|
173
203
|
const HOOK_RUNNER_PATH = join(import.meta.dir, 'hook-runner.ts');
|
|
174
204
|
/**
|
|
@@ -291,6 +321,13 @@ export interface ExecuteHookOptions {
|
|
|
291
321
|
* fleet-credential operation is REFUSED, naming the gap (Rule 6.4).
|
|
292
322
|
*/
|
|
293
323
|
remoteAccess?: RemoteAccessPolicy;
|
|
324
|
+
/**
|
|
325
|
+
* The module's hook-owned-state stores (hook-owned-state task 3.5), built
|
|
326
|
+
* lazily so a hook that never touches `context.secrets` / `context.config`
|
|
327
|
+
* pays nothing. Absent means the broker refuses store writes with an error
|
|
328
|
+
* naming the gap — never a silent drop.
|
|
329
|
+
*/
|
|
330
|
+
hookStores?: HookStoresProvider;
|
|
294
331
|
}
|
|
295
332
|
|
|
296
333
|
/**
|
|
@@ -333,6 +370,7 @@ export async function executeHookScript(
|
|
|
333
370
|
scriptPath,
|
|
334
371
|
logger,
|
|
335
372
|
onActivity: markActive,
|
|
373
|
+
stores: options.hookStores,
|
|
336
374
|
});
|
|
337
375
|
let remoteBroker: Awaited<ReturnType<typeof startRemoteBroker>> | undefined;
|
|
338
376
|
|
|
@@ -367,6 +405,12 @@ export async function executeHookScript(
|
|
|
367
405
|
|
|
368
406
|
const child = Bun.spawn({
|
|
369
407
|
cmd: [...jail.cmd],
|
|
408
|
+
// `sandbox-exec` has no `--chdir`, so the plan names one and the spawn
|
|
409
|
+
// sets it. Without it the child inherits the operator's shell directory,
|
|
410
|
+
// which the profile does not name — and `bun` reads its cwd before it
|
|
411
|
+
// runs anything, so the hook dies with a message about nothing in
|
|
412
|
+
// particular. bubblewrap plans leave this absent and use `--chdir`.
|
|
413
|
+
...(jail.cwd ? { cwd: jail.cwd } : {}),
|
|
370
414
|
env: hookChildEnv(
|
|
371
415
|
broker.socketPath,
|
|
372
416
|
remoteBroker.socketPath,
|
|
@@ -510,6 +554,10 @@ export function hookChildEnv(
|
|
|
510
554
|
const env: Record<string, string> = {};
|
|
511
555
|
|
|
512
556
|
for (const name of FORWARDED_ENV) {
|
|
557
|
+
if (name === 'PATH') {
|
|
558
|
+
env.PATH = childPath(process.env.PATH);
|
|
559
|
+
continue;
|
|
560
|
+
}
|
|
513
561
|
const value = process.env[name];
|
|
514
562
|
if (value !== undefined) env[name] = value;
|
|
515
563
|
}
|
|
@@ -558,19 +606,36 @@ function jailRequest(
|
|
|
558
606
|
|
|
559
607
|
/**
|
|
560
608
|
* Say what the jail did, once per run, at a level that matches how surprising
|
|
561
|
-
* it is.
|
|
609
|
+
* it is. A dropped row is now either FATAL or SILENT, never a warning.
|
|
610
|
+
*
|
|
611
|
+
* This used to warn about every absent row, because it could not tell a routine
|
|
612
|
+
* absence from a damaging one: "both look identical here, so the line names the
|
|
613
|
+
* paths and lets a reader tell them apart." That reasoning was sound and the
|
|
614
|
+
* result was not. The line fired 96 times with a byte-identical payload in a
|
|
615
|
+
* single `cele2e run --all`, so the case it existed to catch was buried in
|
|
616
|
+
* ninety-six copies of the case that does not matter. A signal that repeats
|
|
617
|
+
* unchanged is one every reader learns to skip.
|
|
618
|
+
*
|
|
619
|
+
* Each row now states what its own absence means (`MountAbsence`), so there is
|
|
620
|
+
* no classification left for a human to do from a path string:
|
|
562
621
|
*
|
|
563
|
-
*
|
|
564
|
-
*
|
|
565
|
-
*
|
|
566
|
-
*
|
|
567
|
-
* here, so the line names the paths and lets a reader tell them apart.
|
|
622
|
+
* required throw. The hook cannot do what it was asked.
|
|
623
|
+
* declared-only silent until a module can declare the facility (ce-qani).
|
|
624
|
+
* runtime silent. A genuinely needed one fails the runtime, louder.
|
|
625
|
+
* conditional silent. Expected at this point in the lifecycle.
|
|
568
626
|
*/
|
|
569
627
|
function reportJail(plan: JailPlan, logger: HookLogger): void {
|
|
570
628
|
if (plan.mode === 'jailed') {
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
629
|
+
const fatal = plan.skipped.filter((m) => m.absence === 'required');
|
|
630
|
+
if (fatal.length > 0) {
|
|
631
|
+
// Throw rather than warn. A dropped contract input means the hook writes
|
|
632
|
+
// into the run's PRIVATE TMPFS and reports success over a directory that
|
|
633
|
+
// is discarded when it exits (task 4.2j), and a dropped interpreter or
|
|
634
|
+
// module tree means it could never have run at all. Both were previously
|
|
635
|
+
// survivable-looking warnings.
|
|
636
|
+
const detail = fatal.map((m) => `${m.path} (${m.reason})`).join(', ');
|
|
637
|
+
throw new Error(
|
|
638
|
+
`Hook jail cannot be built: ${fatal.length} required mount(s) absent on this host: ${detail}`,
|
|
574
639
|
);
|
|
575
640
|
}
|
|
576
641
|
return;
|
|
@@ -636,6 +701,13 @@ export interface InvokeHookOptions {
|
|
|
636
701
|
* refused (Rule 6.4).
|
|
637
702
|
*/
|
|
638
703
|
remoteAccess?: RemoteAccessPolicy;
|
|
704
|
+
/**
|
|
705
|
+
* The module's hook-owned-state stores (hook-owned-state task 3.5), as a
|
|
706
|
+
* lazy provider: `() => createHookStores(db, moduleId)`. Built on first use
|
|
707
|
+
* inside the run, so a hook that never persists state never reads the
|
|
708
|
+
* manifest or touches the master key.
|
|
709
|
+
*/
|
|
710
|
+
hookStores?: HookStoresProvider;
|
|
639
711
|
}
|
|
640
712
|
|
|
641
713
|
/**
|
|
@@ -879,7 +951,11 @@ export async function invokeHook(
|
|
|
879
951
|
|
|
880
952
|
// Build context
|
|
881
953
|
const loadedCapabilities = options.capabilities ?? {};
|
|
882
|
-
|
|
954
|
+
// The store accessor methods attach on the CHILD side (hook-runner.ts,
|
|
955
|
+
// buildStoreView) where the socket is, so the object serialized here
|
|
956
|
+
// carries the map half only. Same cast rationale as the runner's own
|
|
957
|
+
// context build. (hook-owned-state task 3.5)
|
|
958
|
+
const context = {
|
|
883
959
|
...inputs,
|
|
884
960
|
config,
|
|
885
961
|
secrets,
|
|
@@ -889,7 +965,7 @@ export async function invokeHook(
|
|
|
889
965
|
screenshotDir,
|
|
890
966
|
stateDir,
|
|
891
967
|
capabilities: loadedCapabilities,
|
|
892
|
-
};
|
|
968
|
+
} as unknown as HookContext;
|
|
893
969
|
|
|
894
970
|
// Pre-flight: every required capability must be loaded (HOOK_API_V2 D3).
|
|
895
971
|
// Fail before invoking the handler so the script never sees a missing
|
|
@@ -939,6 +1015,7 @@ export async function invokeHook(
|
|
|
939
1015
|
idleTimeoutMs,
|
|
940
1016
|
jail: { modulePath, pathInputs: declaredPathInputs(signature, inputs) },
|
|
941
1017
|
remoteAccess: options.remoteAccess,
|
|
1018
|
+
hookStores: options.hookStores,
|
|
942
1019
|
});
|
|
943
1020
|
|
|
944
1021
|
// Validate outputs against the contract signature
|
|
@@ -37,6 +37,7 @@ import { dirname, join, resolve } from 'node:path';
|
|
|
37
37
|
import { executeHookScript } from './executor';
|
|
38
38
|
import { detectJailBackend } from './jail';
|
|
39
39
|
import { createCapturingLogger } from './logger';
|
|
40
|
+
import { configStore, secretStore } from './test-fixtures/store-backed';
|
|
40
41
|
import type { HookContext } from './types';
|
|
41
42
|
|
|
42
43
|
const PROBE_HOOK = resolve(__dirname, 'test-fixtures/jail-toolchain-hook.ts');
|
|
@@ -115,8 +116,8 @@ async function measureReach(): Promise<{ reach: Reach; cleanup: () => void }> {
|
|
|
115
116
|
|
|
116
117
|
const { logger } = createCapturingLogger();
|
|
117
118
|
const context: HookContext = {
|
|
118
|
-
config: PROBE_BROWSER ? { browser_executable: PROBE_BROWSER } :
|
|
119
|
-
secrets:
|
|
119
|
+
config: PROBE_BROWSER ? configStore({ browser_executable: PROBE_BROWSER }) : configStore(),
|
|
120
|
+
secrets: secretStore(),
|
|
120
121
|
systems: [],
|
|
121
122
|
logger,
|
|
122
123
|
debug: false,
|
|
@@ -125,22 +126,38 @@ async function measureReach(): Promise<{ reach: Reach; cleanup: () => void }> {
|
|
|
125
126
|
capabilities: {},
|
|
126
127
|
};
|
|
127
128
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
129
|
+
// The jailed run jails under `required`: ce-29z made `auto` defer on
|
|
130
|
+
// sandbox-exec until D14 exists, so this suite's jail is the operator's
|
|
131
|
+
// explicit act — the bypass the deferral deliberately leaves open.
|
|
132
|
+
const savedPolicy = process.env.CELILO_HOOK_JAIL;
|
|
133
|
+
process.env.CELILO_HOOK_JAIL = 'required';
|
|
134
|
+
try {
|
|
135
|
+
const reach = (await executeHookScript(PROBE_HOOK, context, {
|
|
136
|
+
timeoutMs: 60_000,
|
|
137
|
+
idleTimeoutMs: 60_000,
|
|
138
|
+
jail: {
|
|
139
|
+
modulePath,
|
|
140
|
+
// The browser reaches the jail as a DECLARED PATH INPUT, which is the
|
|
141
|
+
// mechanism 4.10's fix would use. Binding it by editing the derivation
|
|
142
|
+
// and then asking the derivation whether it is bound would prove
|
|
143
|
+
// nothing.
|
|
144
|
+
pathInputs: PROBE_BROWSER
|
|
145
|
+
? [{ name: 'browser_executable', value: probeBrowserDir(), access: 'read' as const }]
|
|
146
|
+
: [],
|
|
147
|
+
},
|
|
148
|
+
})) as unknown as Reach;
|
|
149
|
+
|
|
150
|
+
return { reach, cleanup: restorePolicy };
|
|
151
|
+
} catch (error) {
|
|
152
|
+
restorePolicy();
|
|
153
|
+
throw error;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function restorePolicy() {
|
|
157
|
+
if (savedPolicy === undefined) delete process.env.CELILO_HOOK_JAIL;
|
|
158
|
+
else process.env.CELILO_HOOK_JAIL = savedPolicy;
|
|
159
|
+
rmSync(scratch, { recursive: true, force: true });
|
|
160
|
+
}
|
|
144
161
|
}
|
|
145
162
|
|
|
146
163
|
describe.skipIf(!jailed)(`what a jailed hook can run (backend: ${availability.backend})`, () => {
|
|
@@ -157,18 +174,46 @@ describe.skipIf(!jailed)(`what a jailed hook can run (backend: ${availability.ba
|
|
|
157
174
|
console.error(` ${probe.succeeded ? 'REACHED' : 'ABSENT '} ${name}: ${probe.detail}`);
|
|
158
175
|
}
|
|
159
176
|
|
|
160
|
-
// ── The finding
|
|
161
|
-
//
|
|
162
|
-
//
|
|
163
|
-
|
|
164
|
-
|
|
177
|
+
// ── The finding, per backend ────────────────────────────────────────
|
|
178
|
+
// bubblewrap BUILDS a namespace, so reach is filesystem absence: no
|
|
179
|
+
// `/bin` is bound, so no shell, so `execSync` cannot run at all. 22
|
|
180
|
+
// module script files import `node:child_process`.
|
|
181
|
+
//
|
|
182
|
+
// `sandbox-exec` FILTERS the tree that is already there, and its parity
|
|
183
|
+
// statements change what exec means. `(allow process*)` lets the kernel
|
|
184
|
+
// map and run an image the jail cannot READ — exec is not file-read in
|
|
185
|
+
// SBPL — so `/bin/sh` runs, `/bin/echo` runs, and `ssh` spawns. Measured
|
|
186
|
+
// 2026-08-30, first run of this suite under the second backend. The
|
|
187
|
+
// boundary that DOES hold on macOS is the filesystem and the credential:
|
|
188
|
+
// writes outside the mount set are EPERM, secrets are unreadable, and
|
|
189
|
+
// the `~/.ssh` key stage 3 withheld is not in the jail for ssh to use —
|
|
190
|
+
// asserted by hook-trespass.test.ts and hook-jail-unreachability.test.ts,
|
|
191
|
+
// which pass under both backends.
|
|
192
|
+
if (availability.backend === 'sandbox-exec') {
|
|
193
|
+
expect(reach.shell.succeeded).toBe(true);
|
|
194
|
+
expect(reach.exec_without_shell.succeeded).toBe(true);
|
|
195
|
+
// It spawns; it cannot authenticate. That is stage 3's boundary, not
|
|
196
|
+
// this suite's.
|
|
197
|
+
expect(reach.ssh.succeeded).toBe(true);
|
|
198
|
+
// `ansible-playbook` is not installed on the probe host, so the row
|
|
199
|
+
// measures the HOST, not the jail: posix_spawn of anything present is
|
|
200
|
+
// allowed. No assertion either way.
|
|
201
|
+
// `dns_lookup` also stays a printed row rather than an assertion:
|
|
202
|
+
// measured REACHED — `(allow network*)` plus macOS resolving
|
|
203
|
+
// out-of-process in mDNSResponder — but gating on it needs a live
|
|
204
|
+
// resolver, which a unit suite should not require.
|
|
205
|
+
} else {
|
|
206
|
+
expect(reach.shell.succeeded).toBe(false);
|
|
207
|
+
expect(reach.exec_without_shell.succeeded).toBe(false);
|
|
165
208
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
209
|
+
// `~/.ssh` is bound read-only in stage 2 specifically so `remote.ts`
|
|
210
|
+
// keeps working. There is no `ssh` to hand that key to.
|
|
211
|
+
expect(reach.ssh.succeeded).toBe(false);
|
|
212
|
+
expect(reach.ansible.succeeded).toBe(false);
|
|
213
|
+
}
|
|
170
214
|
|
|
171
|
-
// Nothing binds the resolver's configuration
|
|
215
|
+
// Nothing binds the resolver's configuration, on either platform:
|
|
216
|
+
// bubblewrap by absence, sandbox-exec by `deny default` (EPERM).
|
|
172
217
|
expect(reach.resolv_conf.succeeded).toBe(false);
|
|
173
218
|
|
|
174
219
|
// ── Task 4.10 ──────────────────────────────────────────────────────
|
|
@@ -209,7 +254,12 @@ describe.skipIf(!jailed)(`what a jailed hook can run (backend: ${availability.ba
|
|
|
209
254
|
// edit that would flip it: someone reads the failures above, binds
|
|
210
255
|
// `/usr/bin` to fix them, and brings `bwrap` in with it. `isForbidden`
|
|
211
256
|
// compares whole paths, so a bind of the DIRECTORY passes that filter.
|
|
212
|
-
|
|
257
|
+
// Asserted under bubblewrap only: there is no bubblewrap jail on macOS,
|
|
258
|
+
// so the row would measure whether the OPERATOR has bwrap installed,
|
|
259
|
+
// and posix_spawn of it is allowed anyway.
|
|
260
|
+
if (availability.backend !== 'sandbox-exec') {
|
|
261
|
+
expect(reach.bwrap_present.succeeded).toBe(false);
|
|
262
|
+
}
|
|
213
263
|
} finally {
|
|
214
264
|
cleanup();
|
|
215
265
|
}
|
|
@@ -33,8 +33,9 @@ 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
|
+
import { configStore, secretStore } from './test-fixtures/store-backed';
|
|
38
39
|
import type { HookContext } from './types';
|
|
39
40
|
|
|
40
41
|
const PROBE_HOOK = resolve(__dirname, 'test-fixtures/jail-probe-hook.ts');
|
|
@@ -45,7 +46,12 @@ interface Probe {
|
|
|
45
46
|
}
|
|
46
47
|
|
|
47
48
|
const availability = detectJailBackend();
|
|
48
|
-
|
|
49
|
+
// A host with a backend is not a host that JAILS: `CELILO_HOOK_JAIL=off` is an
|
|
50
|
+
// operator switch and the executor honours it, so without this the suite runs
|
|
51
|
+
// its assertions against a deliberately unjailed hook and reports the jail
|
|
52
|
+
// broken. Only reachable since macOS gained a backend (task 4.8) — before that
|
|
53
|
+
// every Mac skipped for want of one and the hole never showed.
|
|
54
|
+
const jailed = availability.backend !== 'none' && jailPolicy() !== 'off';
|
|
49
55
|
|
|
50
56
|
interface Rig {
|
|
51
57
|
outputs: {
|
|
@@ -105,12 +111,12 @@ async function runProbe(): Promise<Rig> {
|
|
|
105
111
|
|
|
106
112
|
const { logger } = createCapturingLogger();
|
|
107
113
|
const context: HookContext = {
|
|
108
|
-
config: {
|
|
114
|
+
config: configStore({
|
|
109
115
|
planted_secret: plantedSecret,
|
|
110
116
|
sibling_file: siblingFile,
|
|
111
117
|
staged_input: stagedInput,
|
|
112
|
-
},
|
|
113
|
-
secrets:
|
|
118
|
+
}),
|
|
119
|
+
secrets: secretStore(),
|
|
114
120
|
systems: [],
|
|
115
121
|
logger,
|
|
116
122
|
debug: false,
|
|
@@ -119,26 +125,38 @@ async function runProbe(): Promise<Rig> {
|
|
|
119
125
|
capabilities: {},
|
|
120
126
|
};
|
|
121
127
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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 outputs = (await executeHookScript(PROBE_HOOK, context, {
|
|
135
|
+
timeoutMs: 60_000,
|
|
136
|
+
idleTimeoutMs: 60_000,
|
|
137
|
+
jail: {
|
|
138
|
+
modulePath,
|
|
139
|
+
pathInputs: [{ name: 'staged_input', value: stagedInput, access: 'write' }],
|
|
140
|
+
},
|
|
141
|
+
})) as unknown as Rig['outputs'];
|
|
142
|
+
return {
|
|
143
|
+
outputs,
|
|
144
|
+
stagedInput,
|
|
145
|
+
siblingFile,
|
|
146
|
+
cleanup: () => {
|
|
147
|
+
if (savedPolicy === undefined) delete process.env.CELILO_HOOK_JAIL;
|
|
148
|
+
else process.env.CELILO_HOOK_JAIL = savedPolicy;
|
|
149
|
+
rmSync(scratch, { recursive: true, force: true });
|
|
150
|
+
rmSync(stagedInput, { recursive: true, force: true });
|
|
151
|
+
rmSync(plantedKey, { force: true });
|
|
152
|
+
if (createdSshDir) rmSync(sshDir, { recursive: true, force: true });
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
} catch (error) {
|
|
156
|
+
if (savedPolicy === undefined) delete process.env.CELILO_HOOK_JAIL;
|
|
157
|
+
else process.env.CELILO_HOOK_JAIL = savedPolicy;
|
|
158
|
+
throw error;
|
|
159
|
+
}
|
|
142
160
|
}
|
|
143
161
|
|
|
144
162
|
describe.skipIf(!jailed)(`the jail is real (backend: ${availability.backend})`, () => {
|
|
@@ -187,13 +205,21 @@ describe.skipIf(!jailed)(`the jail is real (backend: ${availability.backend})`,
|
|
|
187
205
|
}, 90_000);
|
|
188
206
|
});
|
|
189
207
|
|
|
190
|
-
describe.skipIf(jailed)('
|
|
208
|
+
describe.skipIf(jailed)('this run is not jailed', () => {
|
|
191
209
|
test('says so, rather than reporting a pass it did not earn', () => {
|
|
192
210
|
// Not an assertion about the product. It is the line that stops a green
|
|
193
|
-
// run on
|
|
211
|
+
// run on an unjailed host reading as "the jail was proven".
|
|
212
|
+
const why =
|
|
213
|
+
availability.backend === 'none'
|
|
214
|
+
? (availability.reason ?? 'no backend')
|
|
215
|
+
: `CELILO_HOOK_JAIL=${process.env.CELILO_HOOK_JAIL}, so the operator switched the ${availability.backend} jail off here`;
|
|
194
216
|
console.log(
|
|
195
|
-
`\nhook jail: SKIPPED the live suite — ${
|
|
217
|
+
`\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
218
|
);
|
|
197
|
-
|
|
219
|
+
// Both reasons are legitimate and they are different facts, so assert the
|
|
220
|
+
// disjunction rather than one of them. Asserting `backend === 'none'` alone
|
|
221
|
+
// went red the day macOS got a backend AND the operator turned it off — a
|
|
222
|
+
// combination that is not a defect in anything.
|
|
223
|
+
expect(availability.backend === 'none' || jailPolicy() === 'off').toBe(true);
|
|
198
224
|
});
|
|
199
225
|
});
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
|
|
26
26
|
import { z } from 'zod';
|
|
27
27
|
|
|
28
|
-
export const HOOK_PROTOCOL_VERSION =
|
|
28
|
+
export const HOOK_PROTOCOL_VERSION = 2;
|
|
29
29
|
|
|
30
30
|
/** Environment variable carrying the broker's socket path to the child. */
|
|
31
31
|
export const HOOK_SOCKET_ENV = 'CELILO_HOOK_SOCKET';
|
|
@@ -91,6 +91,39 @@ export const CallFrameSchema = z.object({
|
|
|
91
91
|
args: z.array(z.unknown()),
|
|
92
92
|
});
|
|
93
93
|
|
|
94
|
+
/**
|
|
95
|
+
* One hook-owned-state store operation, correlated like a `call` (and answered
|
|
96
|
+
* by the same `return`/`throw` frames — the runner's pending map keys on `id`
|
|
97
|
+
* alone).
|
|
98
|
+
*
|
|
99
|
+
* `store` names which of the module's two stores the call targets; `method` is
|
|
100
|
+
* one of the four `HookStore` operations. A `transaction` call carries the
|
|
101
|
+
* buffered operation list the CHILD accumulated — the hook's `fn` ran entirely
|
|
102
|
+
* child-side, so a throwing `fn` never sends anything, which is what makes the
|
|
103
|
+
* discard real rather than a promise the parent has to honor.
|
|
104
|
+
*
|
|
105
|
+
* Hook-owned-state design: the accessor is implemented once broker-side and
|
|
106
|
+
* reaches the hook as its own RPC family over this envelope, NOT as a fake
|
|
107
|
+
* capability — a fake one would surface in the `capabilities` shape frame and
|
|
108
|
+
* appear to the hook as a provider module.
|
|
109
|
+
*/
|
|
110
|
+
export const StoreCallFrameSchema = z.object({
|
|
111
|
+
type: z.literal('store'),
|
|
112
|
+
id: z.string(),
|
|
113
|
+
store: z.enum(['secrets', 'config']),
|
|
114
|
+
method: z.enum(['get', 'set', 'delete', 'transaction']),
|
|
115
|
+
args: z.array(z.unknown()),
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
/** One buffered operation inside a `transaction` store call. */
|
|
119
|
+
export const BufferedStoreOpSchema = z.object({
|
|
120
|
+
op: z.enum(['set', 'delete']),
|
|
121
|
+
name: z.string(),
|
|
122
|
+
value: z.string().optional(),
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
export type BufferedStoreOp = z.infer<typeof BufferedStoreOpSchema>;
|
|
126
|
+
|
|
94
127
|
/** One `ctx.logger` call. Fire and forget — the hook does not wait on it. */
|
|
95
128
|
export const LogFrameSchema = z.object({
|
|
96
129
|
type: z.literal('log'),
|
|
@@ -113,6 +146,7 @@ export const HookThrewFrameSchema = z.object({
|
|
|
113
146
|
export const ChildFrameSchema = z.discriminatedUnion('type', [
|
|
114
147
|
ReadyFrameSchema,
|
|
115
148
|
CallFrameSchema,
|
|
149
|
+
StoreCallFrameSchema,
|
|
116
150
|
LogFrameSchema,
|
|
117
151
|
ResultFrameSchema,
|
|
118
152
|
HookThrewFrameSchema,
|
|
@@ -145,6 +179,11 @@ export const MountEntrySchema = z.object({
|
|
|
145
179
|
path: z.string(),
|
|
146
180
|
mode: z.enum(['ro', 'rw', 'tmpfs']),
|
|
147
181
|
reason: z.string(),
|
|
182
|
+
// Kept on the wire so this stays the structural twin its docblock below
|
|
183
|
+
// claims. The child ignores it — absence is decided when the set is derived,
|
|
184
|
+
// not by the hook — but a twin that has quietly stopped being one is how the
|
|
185
|
+
// round-trip test starts asserting about a shape nothing sends.
|
|
186
|
+
absence: z.enum(['required', 'declared-only', 'runtime', 'conditional']),
|
|
148
187
|
});
|
|
149
188
|
|
|
150
189
|
/** The derived mount set, on the wire. Structural twin of `MountSet`. */
|
|
@@ -272,6 +311,12 @@ export function createLineReader(onLine: (line: string) => void): (chunk: string
|
|
|
272
311
|
/**
|
|
273
312
|
* The handshake check, in one place so both ends produce the same sentence.
|
|
274
313
|
*
|
|
314
|
+
* Version 2 added the `store` frame family (hook-owned-state task 3.5). Both
|
|
315
|
+
* ends of this protocol ship in the same celilo binary, so the check exists
|
|
316
|
+
* for one situation: a runner answering a broker built from different source
|
|
317
|
+
* than itself. It aborts the hook loudly instead of letting a store write
|
|
318
|
+
* disappear into a frame the other side cannot parse.
|
|
319
|
+
*
|
|
275
320
|
* Names both numbers: a mismatch is an install skew (a `.deb` upgraded while a
|
|
276
321
|
* module's bundled copy was not), and the operator needs to know which side is
|
|
277
322
|
* which to fix it.
|
package/src/hooks/hook-runner.ts
CHANGED
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
serializeError,
|
|
27
27
|
versionMismatch,
|
|
28
28
|
} from './hook-protocol';
|
|
29
|
+
import { buildStoreView } from './hook-store-proxy';
|
|
29
30
|
import type { HookContext, HookLogger } from './types';
|
|
30
31
|
import { forwardLintWarnings } from './unjailed-lint';
|
|
31
32
|
|
|
@@ -110,6 +111,22 @@ function callBroker(capability: string, method: string, args: unknown[]): Promis
|
|
|
110
111
|
});
|
|
111
112
|
}
|
|
112
113
|
|
|
114
|
+
/**
|
|
115
|
+
* Store calls share the capability call's correlation machinery: the broker
|
|
116
|
+
* answers both families with the same `return`/`throw` frames, keyed by `id`.
|
|
117
|
+
*/
|
|
118
|
+
function callStore(
|
|
119
|
+
store: 'secrets' | 'config',
|
|
120
|
+
method: 'get' | 'set' | 'delete' | 'transaction',
|
|
121
|
+
args: unknown[],
|
|
122
|
+
): Promise<unknown> {
|
|
123
|
+
const id = `c${nextCallId++}`;
|
|
124
|
+
return new Promise((resolve, reject) => {
|
|
125
|
+
pending.set(id, { resolve, reject });
|
|
126
|
+
send({ type: 'store', id, store, method, args });
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
113
130
|
/**
|
|
114
131
|
* The `defineHook` brand check, moved here from the executor unchanged
|
|
115
132
|
* (HOOK_API_V2 Phase 8 / D8). The brand is a `Symbol.for` key, so it survives
|
|
@@ -133,8 +150,27 @@ async function runHook(): Promise<void> {
|
|
|
133
150
|
// hook's logger from here on (anything earlier was buffered).
|
|
134
151
|
forwardLintWarnings(logger.warn);
|
|
135
152
|
|
|
153
|
+
// Attach the hook-owned-state accessor onto both maps. The maps crossed
|
|
154
|
+
// the context frame as plain data; the accessor methods exist only on
|
|
155
|
+
// this side, where the socket is. Old map reads keep working; new code
|
|
156
|
+
// calls `secrets.set(...)` / `config.get(...)` (hook-owned-state task 3.5).
|
|
157
|
+
// The casts say something true: celilo built both fields and controls
|
|
158
|
+
// their shape, the same rationale as the field check above.
|
|
159
|
+
const stores = buildStoreView(
|
|
160
|
+
'secrets',
|
|
161
|
+
(contextData.secrets ?? {}) as Record<string, string>,
|
|
162
|
+
callStore,
|
|
163
|
+
);
|
|
164
|
+
const configStores = buildStoreView(
|
|
165
|
+
'config',
|
|
166
|
+
(contextData.config ?? {}) as Record<string, unknown>,
|
|
167
|
+
callStore,
|
|
168
|
+
);
|
|
169
|
+
|
|
136
170
|
const context = {
|
|
137
171
|
...contextData,
|
|
172
|
+
config: configStores,
|
|
173
|
+
secrets: stores,
|
|
138
174
|
logger,
|
|
139
175
|
capabilities: buildCapabilities(shape),
|
|
140
176
|
} as unknown as HookContext;
|