@celilo/cli 1.12.0 → 1.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CELILO_CORE_MODULES.md +2 -1
- package/CELILO_SUBSYSTEMS.md +21 -2
- package/package.json +3 -3
- package/src/capabilities/public-web-helpers.test.ts +12 -6
- package/src/capabilities/public-web-publish.test.ts +24 -13
- package/src/capabilities/validation.test.ts +31 -0
- package/src/cli/commands/alerts-list.ts +16 -1
- package/src/cli/commands/backup-list.test.ts +82 -1
- package/src/cli/commands/backup-list.ts +113 -4
- package/src/cli/commands/console-get-chain.test.ts +96 -0
- package/src/cli/commands/console.ts +130 -0
- package/src/cli/commands/module-list.ts +3 -41
- package/src/cli/commands/notify-config.test.ts +79 -0
- package/src/cli/commands/notify-config.ts +13 -2
- package/src/cli/commands/system-ensure-fleet-key.ts +52 -0
- package/src/cli/completion.ts +6 -0
- package/src/cli/index.ts +31 -1
- package/src/console/closure.test.ts +322 -0
- package/src/console/closure.ts +294 -0
- package/src/console/control-plane-boundary.test.ts +75 -0
- package/src/console/projection.test.ts +293 -0
- package/src/console/projection.ts +364 -0
- package/src/db/schema.ts +19 -14
- package/src/hooks/broker.test.ts +4 -6
- package/src/hooks/capability-loader-control-plane-api.test.ts +124 -0
- package/src/hooks/capability-loader.ts +67 -10
- package/src/hooks/executor.test.ts +85 -4
- package/src/hooks/executor.ts +164 -9
- package/src/hooks/hook-jail-unreachability.test.ts +173 -0
- package/src/hooks/hook-state-dir.test.ts +14 -2
- package/src/hooks/hook-timeout.test.ts +2 -4
- package/src/hooks/hook-trespass.test.ts +50 -5
- package/src/hooks/jail.test.ts +370 -0
- package/src/hooks/jail.ts +491 -0
- package/src/hooks/mount-set.ts +24 -0
- package/src/hooks/test-fixtures/jail-probe-hook.ts +59 -0
- package/src/manifest/contracts/v1.ts +22 -1
- package/src/manifest/schema.ts +35 -0
- package/src/manifest/validate.test.ts +142 -0
- package/src/manifest/validate.ts +126 -4
- package/src/module/import.test.ts +116 -0
- package/src/module/import.ts +73 -1
- package/src/module/packaging/audit.ts +103 -1
- package/src/module/packaging/classify-module-path.test.ts +36 -0
- package/src/module/packaging/package-rules.ts +18 -0
- package/src/module/web-root.ts +35 -0
- package/src/policy/capability-shape-baseline.ts +8 -0
- package/src/policy/module-business-baseline.ts +26 -2
- package/src/policy/module-script-scan.test.ts +22 -0
- package/src/policy/module-script-scan.ts +32 -0
- package/src/services/alerting/observed-health.ts +71 -0
- package/src/services/api-principal-enrolment.test.ts +252 -0
- package/src/services/api-principal-enrolment.ts +158 -0
- package/src/services/audit/backups.ts +10 -1
- package/src/services/backup-create.ts +33 -7
- package/src/services/backup-metadata.ts +19 -11
- package/src/services/celilo-mgmt-hooks.test.ts +38 -79
- package/src/services/consumer-cleanup.ts +31 -5
- package/src/services/fleet-key.test.ts +47 -0
- package/src/services/fleet-key.ts +75 -0
- package/src/services/instance-ops.test.ts +302 -0
- package/src/services/instance-ops.ts +292 -0
- package/src/services/module-instances.test.ts +428 -42
- package/src/services/module-instances.ts +219 -26
- package/src/services/restore-from-file.ts +6 -5
- package/src/services/system-state-stage.test.ts +165 -0
- package/src/services/system-state-stage.ts +196 -0
|
@@ -15,6 +15,7 @@ import { join } from 'node:path';
|
|
|
15
15
|
import {
|
|
16
16
|
createPublicWeb,
|
|
17
17
|
isCompiledCapabilityFactory,
|
|
18
|
+
orderFirewallChain,
|
|
18
19
|
wrapWithLogging,
|
|
19
20
|
} from '@celilo/capabilities';
|
|
20
21
|
import type {
|
|
@@ -38,8 +39,10 @@ import {
|
|
|
38
39
|
systemConfig,
|
|
39
40
|
webRoutes,
|
|
40
41
|
} from '../db/schema';
|
|
42
|
+
import { resolveModuleWebRoot } from '../module/web-root';
|
|
41
43
|
import { decryptSecret } from '../secrets/encryption';
|
|
42
44
|
import { getOrCreateMasterKey } from '../secrets/master-key';
|
|
45
|
+
import { buildControlPlaneApi } from '../services/api-principal-enrolment';
|
|
43
46
|
import { recordCapabilityBinding, withBindingRecord } from '../services/capability-bindings';
|
|
44
47
|
import { emitWebRoutesChangedAndWait } from '../services/celilo-events';
|
|
45
48
|
import { getModuleSystems } from '../services/deployed-systems';
|
|
@@ -219,6 +222,26 @@ export async function resolveCaddyZoneIp(db: DbClient): Promise<string | undefin
|
|
|
219
222
|
return undefined;
|
|
220
223
|
}
|
|
221
224
|
|
|
225
|
+
/**
|
|
226
|
+
* Does this module's manifest declare `capabilityName`, under `requires` or
|
|
227
|
+
* `optional`?
|
|
228
|
+
*
|
|
229
|
+
* Reads the manifest stored at import rather than the one on disk, so the
|
|
230
|
+
* answer is the one the operator's `module import` actually validated.
|
|
231
|
+
*/
|
|
232
|
+
function consumerDeclares(db: DbClient, moduleId: string, capabilityName: string): boolean {
|
|
233
|
+
const row = db.select().from(modules).where(eq(modules.id, moduleId)).get();
|
|
234
|
+
const manifest = row?.manifestData;
|
|
235
|
+
if (!manifest) return false;
|
|
236
|
+
|
|
237
|
+
type Declarations = { capabilities?: { name?: string }[] } | undefined;
|
|
238
|
+
const declared = [
|
|
239
|
+
...((manifest.requires as Declarations)?.capabilities ?? []),
|
|
240
|
+
...((manifest.optional as Declarations)?.capabilities ?? []),
|
|
241
|
+
];
|
|
242
|
+
return declared.some((cap) => cap?.name === capabilityName);
|
|
243
|
+
}
|
|
244
|
+
|
|
222
245
|
export async function loadCapabilityFunctions(
|
|
223
246
|
consumingModuleId: string,
|
|
224
247
|
db: DbClient,
|
|
@@ -380,6 +403,11 @@ export async function loadCapabilityFunctions(
|
|
|
380
403
|
// which made a module-provided capability structurally unable to
|
|
381
404
|
// offer `unregisterRoutes()`-style methods.
|
|
382
405
|
consumerModuleId: consumingModuleId,
|
|
406
|
+
// WHERE THE CALLER'S BYTES ARE, for the same reason and by the same
|
|
407
|
+
// seam. `private_web` and `external_web` publish static sites too, and
|
|
408
|
+
// without this they could not find the caller's web root now that
|
|
409
|
+
// `sourceDir` has left the request (D10 amendment).
|
|
410
|
+
consumerWebRoot: resolveModuleWebRoot(consumingModuleId, db),
|
|
383
411
|
});
|
|
384
412
|
// Stamp here too, not only on the legacy path: a consumer that cannot
|
|
385
413
|
// get what it needs must be able to name WHICH provider could not give
|
|
@@ -563,6 +591,9 @@ export async function loadCapabilityFunctions(
|
|
|
563
591
|
providerByCapability.set(provider.capabilityName, provider.moduleId);
|
|
564
592
|
result.public_web = createPublicWeb({
|
|
565
593
|
moduleId: consumingModuleId,
|
|
594
|
+
// Resolved here, not in the capability: on a converge there is no
|
|
595
|
+
// consumer process to ask (D10 amendment).
|
|
596
|
+
webRoot: resolveModuleWebRoot(consumingModuleId, db),
|
|
566
597
|
logger,
|
|
567
598
|
config: providerConfig,
|
|
568
599
|
secrets: providerSecrets,
|
|
@@ -641,6 +672,30 @@ export async function loadCapabilityFunctions(
|
|
|
641
672
|
debugLog('public_web: not registered in DB, skipping');
|
|
642
673
|
}
|
|
643
674
|
|
|
675
|
+
// Framework-granted, so unlike everything above there is no provider row to
|
|
676
|
+
// look up and no script to import — celilo IS the management server whose
|
|
677
|
+
// principals these are (web-ui-console D7b).
|
|
678
|
+
//
|
|
679
|
+
// Gated on the DECLARATION, which the rest of this function deliberately is
|
|
680
|
+
// not: the loop injects every capability it can build "not just required
|
|
681
|
+
// ones", so a module that never asked still gets `idp` and `firewall`. That is
|
|
682
|
+
// fine for a capability whose worst outcome is an unused OIDC client. This one
|
|
683
|
+
// mints an SSH principal into celilo's own control plane, so the `requires`
|
|
684
|
+
// line is the authorization and a module that did not write one does not get
|
|
685
|
+
// the object at all.
|
|
686
|
+
if (consumerDeclares(db, consumingModuleId, 'control_plane_api')) {
|
|
687
|
+
// Wrapped like every other capability, so an enrolment that fails mid-deploy
|
|
688
|
+
// leaves a `✗ control_plane_api.enrol_principal` in the hook log rather than
|
|
689
|
+
// only a thrown error further up. The `defineCapabilityFunction` path wraps
|
|
690
|
+
// itself; a framework-built table has to be wrapped here.
|
|
691
|
+
result.control_plane_api = wrapWithLogging(
|
|
692
|
+
buildControlPlaneApi(consumingModuleId),
|
|
693
|
+
logger,
|
|
694
|
+
'control_plane_api',
|
|
695
|
+
);
|
|
696
|
+
debugLog(`control_plane_api: framework-granted, injected for ${consumingModuleId}`);
|
|
697
|
+
}
|
|
698
|
+
|
|
644
699
|
// celilo#1072: the CALL is the binding, not the resolution. Everything above
|
|
645
700
|
// is injected whether or not the consumer declared it — the loop's own
|
|
646
701
|
// comment says "not just required ones" — so recording what was resolved
|
|
@@ -1104,20 +1159,19 @@ async function buildFirewallChain(
|
|
|
1104
1159
|
// attributable. Only the layers that render their own ruleset receive it.
|
|
1105
1160
|
const trustedSourceStore = buildTrustedSourceStore(db, consumingModuleId);
|
|
1106
1161
|
|
|
1107
|
-
//
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
unknown
|
|
1112
|
-
>;
|
|
1113
|
-
return data.has_external === true;
|
|
1114
|
-
});
|
|
1162
|
+
// The delegation order, most downstream first. Shared with the console's
|
|
1163
|
+
// closure so the wiring and the picture cannot disagree about which firewall
|
|
1164
|
+
// stands on which — see `orderFirewallChain`.
|
|
1165
|
+
const chainOrder = orderFirewallChain(allProviders);
|
|
1115
1166
|
|
|
1116
|
-
if (
|
|
1167
|
+
if (chainOrder.length === 0) {
|
|
1117
1168
|
debugLog('firewall chain: no provider with external interface found');
|
|
1118
1169
|
return { chain: null, self: null };
|
|
1119
1170
|
}
|
|
1120
1171
|
|
|
1172
|
+
// The leaf is the upstream end: the provider with the direct internet leg.
|
|
1173
|
+
const hasExternal = chainOrder[chainOrder.length - 1] as (typeof chainOrder)[number];
|
|
1174
|
+
|
|
1121
1175
|
// Build the leaf (external) firewall first
|
|
1122
1176
|
const leafModule = db.select().from(modules).where(eq(modules.id, hasExternal.moduleId)).get();
|
|
1123
1177
|
if (!leafModule) return { chain: null, self: null };
|
|
@@ -1173,7 +1227,10 @@ async function buildFirewallChain(
|
|
|
1173
1227
|
// Build downstream providers, wiring each to the upstream. iptables
|
|
1174
1228
|
// remains a legacy factory because its second arg (upstreamFirewall)
|
|
1175
1229
|
// doesn't fit defineCapabilityFunction's single-context shape.
|
|
1176
|
-
|
|
1230
|
+
// Back into wiring order: each layer is built taking the previous one as its
|
|
1231
|
+
// upstream, so the build runs from the leaf outwards while the chain reads
|
|
1232
|
+
// from the consumer inwards.
|
|
1233
|
+
const downstream = chainOrder.slice(0, -1).reverse();
|
|
1177
1234
|
|
|
1178
1235
|
// Each layer as its OWN provider sees it, so `on_consumer_removed` converges
|
|
1179
1236
|
// the firewall that declares the hook rather than whichever layer a consumer
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { describe, expect, test } from 'bun:test';
|
|
2
|
-
import { existsSync, readdirSync, rmSync } from 'node:fs';
|
|
2
|
+
import { existsSync, mkdtempSync, readdirSync, rmSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
3
4
|
import { join } from 'node:path';
|
|
4
5
|
import type { ContractHookSignature } from '../manifest/contracts';
|
|
5
6
|
import {
|
|
6
7
|
checkRequiredCapabilities,
|
|
8
|
+
declaredPathInputs,
|
|
7
9
|
executeHookScript,
|
|
8
10
|
invokeHook,
|
|
9
11
|
resolveHookScript,
|
|
@@ -11,6 +13,7 @@ import {
|
|
|
11
13
|
validateHookInputs,
|
|
12
14
|
validateHookOutputs,
|
|
13
15
|
} from './executor';
|
|
16
|
+
import { readJailMode } from './jail';
|
|
14
17
|
import { createCapturingLogger } from './logger';
|
|
15
18
|
import type { HookDefinition } from './types';
|
|
16
19
|
|
|
@@ -241,12 +244,90 @@ describe('Hook Executor', () => {
|
|
|
241
244
|
|
|
242
245
|
// 8s of silence against a 1ms idle bound: killed at the first poll.
|
|
243
246
|
// Under the old hardcoded 30s idle this resolved instead.
|
|
244
|
-
await expect(
|
|
245
|
-
|
|
246
|
-
);
|
|
247
|
+
await expect(
|
|
248
|
+
executeHookScript(scriptPath, context, { timeoutMs: 60_000, idleTimeoutMs: 1 }),
|
|
249
|
+
).rejects.toThrow('idle timeout exceeded');
|
|
247
250
|
}, 20_000);
|
|
248
251
|
});
|
|
249
252
|
|
|
253
|
+
describe('declaredPathInputs', () => {
|
|
254
|
+
// The jail's bind mounts come from this, so a wrong answer here is a hook
|
|
255
|
+
// that cannot read a file it was handed — or one that can read a file
|
|
256
|
+
// nobody declared.
|
|
257
|
+
const SIG: ContractHookSignature = {
|
|
258
|
+
inputs: {
|
|
259
|
+
backup_dir: { required: true, path: { access: 'write' } },
|
|
260
|
+
artifact_path: { required: true, path: { access: 'read' } },
|
|
261
|
+
artifact_count: { required: false },
|
|
262
|
+
},
|
|
263
|
+
outputs: {},
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
test('walks the declaration and carries the declared access', () => {
|
|
267
|
+
expect(
|
|
268
|
+
declaredPathInputs(SIG, {
|
|
269
|
+
backup_dir: '/tmp/stage/data',
|
|
270
|
+
artifact_path: '/tmp/stage/db.sqlite',
|
|
271
|
+
artifact_count: 3,
|
|
272
|
+
}),
|
|
273
|
+
).toEqual([
|
|
274
|
+
{ name: 'backup_dir', value: '/tmp/stage/data', access: 'write' },
|
|
275
|
+
{ name: 'artifact_path', value: '/tmp/stage/db.sqlite', access: 'read' },
|
|
276
|
+
]);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
test('a path-shaped input the contract does not declare contributes nothing', () => {
|
|
280
|
+
// `db_path` was passed by backup-create.ts for months and declared
|
|
281
|
+
// nowhere (celilo#1118). The derivation walks declarations, so the jail
|
|
282
|
+
// withholds it — which is the correct outcome and the reason the
|
|
283
|
+
// contract has to declare what it passes.
|
|
284
|
+
expect(declaredPathInputs(SIG, { db_path: '/var/celilo/celilo.db' })).toEqual([]);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
test('a name that merely LOOKS like a path is not one', () => {
|
|
288
|
+
// Never inferred from the name. A heuristic silently changes what a hook
|
|
289
|
+
// can reach the day somebody adds an input called `workspace`.
|
|
290
|
+
const noPaths: ContractHookSignature = {
|
|
291
|
+
inputs: { restore_dir: { required: true } },
|
|
292
|
+
outputs: {},
|
|
293
|
+
};
|
|
294
|
+
expect(declaredPathInputs(noPaths, { restore_dir: '/tmp/x' })).toEqual([]);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
test('a declared input this run did not receive is skipped', () => {
|
|
298
|
+
expect(declaredPathInputs(SIG, {})).toEqual([]);
|
|
299
|
+
});
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
describe('the recorded jail mode describes the HOST', () => {
|
|
303
|
+
test('an invocation with no module tree records nothing', async () => {
|
|
304
|
+
// Such a run is unjailable whatever the host can do, so recording it
|
|
305
|
+
// would overwrite a `jailed` record with an `unjailed` one — exactly the
|
|
306
|
+
// transition a self-monitor raises an alert on (design D8, task 4.4).
|
|
307
|
+
const store = join(mkdtempSync(join(tmpdir(), 'celilo-mode-')), 'mode.json');
|
|
308
|
+
const saved = process.env.CELILO_HOOK_JAIL_MODE_PATH;
|
|
309
|
+
process.env.CELILO_HOOK_JAIL_MODE_PATH = store;
|
|
310
|
+
try {
|
|
311
|
+
const { logger } = createCapturingLogger();
|
|
312
|
+
await executeHookScript(join(FIXTURES_DIR, 'void-hook.ts'), {
|
|
313
|
+
config: {},
|
|
314
|
+
secrets: {},
|
|
315
|
+
systems: [],
|
|
316
|
+
logger,
|
|
317
|
+
debug: false,
|
|
318
|
+
screenshotDir: '/tmp',
|
|
319
|
+
stateDir: '/tmp',
|
|
320
|
+
capabilities: {},
|
|
321
|
+
});
|
|
322
|
+
expect(readJailMode()).toBeUndefined();
|
|
323
|
+
expect(existsSync(store)).toBe(false);
|
|
324
|
+
} finally {
|
|
325
|
+
if (saved === undefined) delete process.env.CELILO_HOOK_JAIL_MODE_PATH;
|
|
326
|
+
else process.env.CELILO_HOOK_JAIL_MODE_PATH = saved;
|
|
327
|
+
}
|
|
328
|
+
});
|
|
329
|
+
});
|
|
330
|
+
|
|
250
331
|
describe('resolveHookTimeouts', () => {
|
|
251
332
|
test('no declaration: 60s total, 30s idle heuristic', () => {
|
|
252
333
|
expect(resolveHookTimeouts(undefined, false)).toEqual({
|
package/src/hooks/executor.ts
CHANGED
|
@@ -24,16 +24,24 @@
|
|
|
24
24
|
* records after celilo reported the deploy failed (celilo#1003).
|
|
25
25
|
* - A hook's memory is its own process's, not celilo's heap.
|
|
26
26
|
*
|
|
27
|
+
* **Stage 2 has landed and the child is JAILED where a backend exists.** The
|
|
28
|
+
* filesystem view is derived per run (`mount-set.ts`, design D9) and built with
|
|
29
|
+
* bubblewrap (`jail.ts`, design D8). A jailed hook reaching for celilo's master
|
|
30
|
+
* key gets `ENOENT` — the path is not denied, it is absent. Where no backend
|
|
31
|
+
* exists (a Mac today, task 4.8) the hook runs unjailed and the mode is
|
|
32
|
+
* RECORDED, so a host that quietly stops jailing is visible rather than silent.
|
|
33
|
+
*
|
|
27
34
|
* Hooks still do NOT execute on the target machine. One that needs to touch a
|
|
28
35
|
* target initiates SSH outbound itself, so anything it depends on (chromium,
|
|
29
36
|
* system binaries, credentials) must be available on the celilo CLI host.
|
|
30
|
-
* Stage
|
|
31
|
-
*
|
|
37
|
+
* Stage 3 scopes that reachability and has not landed, which is why `~/.ssh`
|
|
38
|
+
* is still bound into the jail read-only.
|
|
32
39
|
*
|
|
33
40
|
* Execution function (Rule 10.1) - performs side effects (script execution)
|
|
34
41
|
*/
|
|
35
42
|
|
|
36
43
|
import { existsSync, mkdirSync, readdirSync, rmdirSync, statSync } from 'node:fs';
|
|
44
|
+
import { homedir } from 'node:os';
|
|
37
45
|
import { dirname, join, resolve } from 'node:path';
|
|
38
46
|
import {
|
|
39
47
|
type DeployedSystem,
|
|
@@ -57,6 +65,16 @@ import {
|
|
|
57
65
|
createLineReader,
|
|
58
66
|
deserializeError,
|
|
59
67
|
} from './hook-protocol';
|
|
68
|
+
import {
|
|
69
|
+
type JailPlan,
|
|
70
|
+
detectJailBackend,
|
|
71
|
+
jailPolicy,
|
|
72
|
+
planJailedSpawn,
|
|
73
|
+
realpathRequest,
|
|
74
|
+
recordJailMode,
|
|
75
|
+
runtimeModulePathsFor,
|
|
76
|
+
} from './jail';
|
|
77
|
+
import { type DeclaredPathInput, type MountSetRequest, deriveMountSet } from './mount-set';
|
|
60
78
|
import type { HookContext, HookDefinition, HookLogger, HookResult } from './types';
|
|
61
79
|
|
|
62
80
|
/** Default total timeout: 60 seconds */
|
|
@@ -218,6 +236,37 @@ export function resolveHookScript(modulePath: string, scriptPath: string): strin
|
|
|
218
236
|
return resolved;
|
|
219
237
|
}
|
|
220
238
|
|
|
239
|
+
/**
|
|
240
|
+
* What the jail needs that only the caller knows.
|
|
241
|
+
*
|
|
242
|
+
* Everything else in a `MountSetRequest` the executor already holds or is: the
|
|
243
|
+
* writable directories are on the context, the socket belongs to the broker it
|
|
244
|
+
* just started, and the runtime and the shim are its own. These two are not.
|
|
245
|
+
* `modulePath` is the module's identity and `pathInputs` needs the contract
|
|
246
|
+
* signature, and neither reaches `executeHookScript` any other way.
|
|
247
|
+
*
|
|
248
|
+
* Absent means "do not jail this invocation" rather than "jail it with
|
|
249
|
+
* nothing" — an empty mount set is a hook that cannot read its own script.
|
|
250
|
+
*/
|
|
251
|
+
export interface HookJailInputs {
|
|
252
|
+
/** The module's own tree, `mod.sourcePath`. Realpath'd here, not by the caller. */
|
|
253
|
+
readonly modulePath: string;
|
|
254
|
+
/** Contract-declared path inputs, paired with the values this run resolved. */
|
|
255
|
+
readonly pathInputs: readonly DeclaredPathInput[];
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export interface ExecuteHookOptions {
|
|
259
|
+
/** Total timeout in milliseconds. */
|
|
260
|
+
timeoutMs?: number;
|
|
261
|
+
/**
|
|
262
|
+
* Kill after this much silence (no output of any kind from the child).
|
|
263
|
+
* Pass `timeoutMs` to disable the idle heuristic — see `IDLE_TIMEOUT_MS`.
|
|
264
|
+
*/
|
|
265
|
+
idleTimeoutMs?: number;
|
|
266
|
+
/** What to jail this run with. Absent runs the hook unjailed. */
|
|
267
|
+
jail?: HookJailInputs;
|
|
268
|
+
}
|
|
269
|
+
|
|
221
270
|
/**
|
|
222
271
|
* Execute a hook script
|
|
223
272
|
*
|
|
@@ -225,17 +274,17 @@ export function resolveHookScript(modulePath: string, scriptPath: string): strin
|
|
|
225
274
|
*
|
|
226
275
|
* @param scriptPath - Absolute path to the hook script
|
|
227
276
|
* @param context - Hook context with config, secrets, logger, and inputs
|
|
228
|
-
* @param
|
|
229
|
-
* @param idleTimeoutMs - Kill after this much silence (no `ctx.logger` call).
|
|
230
|
-
* Pass `timeoutMs` to disable the idle heuristic — see `IDLE_TIMEOUT_MS`.
|
|
277
|
+
* @param options - Timeouts and the jail inputs
|
|
231
278
|
* @returns Hook result with outputs
|
|
232
279
|
*/
|
|
233
280
|
export async function executeHookScript(
|
|
234
281
|
scriptPath: string,
|
|
235
282
|
context: HookContext,
|
|
236
|
-
|
|
237
|
-
idleTimeoutMs: number = IDLE_TIMEOUT_MS,
|
|
283
|
+
options: ExecuteHookOptions = {},
|
|
238
284
|
): Promise<Record<string, unknown>> {
|
|
285
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
286
|
+
const idleTimeoutMs = options.idleTimeoutMs ?? IDLE_TIMEOUT_MS;
|
|
287
|
+
|
|
239
288
|
if (!existsSync(scriptPath)) {
|
|
240
289
|
throw new Error(`Hook script not found: ${scriptPath}`);
|
|
241
290
|
}
|
|
@@ -261,8 +310,22 @@ export async function executeHookScript(
|
|
|
261
310
|
});
|
|
262
311
|
|
|
263
312
|
try {
|
|
313
|
+
const jail = planJailedSpawn(
|
|
314
|
+
[process.execPath, HOOK_RUNNER_PATH],
|
|
315
|
+
options.jail &&
|
|
316
|
+
deriveMountSet(realpathRequest(jailRequest(options.jail, context, broker.socketPath))),
|
|
317
|
+
detectJailBackend(),
|
|
318
|
+
jailPolicy(),
|
|
319
|
+
);
|
|
320
|
+
reportJail(jail, logger);
|
|
321
|
+
// Only a real module hook says anything about whether this HOST jails. An
|
|
322
|
+
// invocation with no module tree is unjailable whatever the host can do,
|
|
323
|
+
// and recording it would overwrite a `jailed` record with an `unjailed`
|
|
324
|
+
// one — which is precisely the transition task 4.4 raises an alert on.
|
|
325
|
+
if (options.jail) recordJailMode(jail);
|
|
326
|
+
|
|
264
327
|
const child = Bun.spawn({
|
|
265
|
-
cmd: [
|
|
328
|
+
cmd: [...jail.cmd],
|
|
266
329
|
env: hookChildEnv(broker.socketPath),
|
|
267
330
|
stdout: 'pipe',
|
|
268
331
|
stderr: 'pipe',
|
|
@@ -376,6 +439,64 @@ export function hookChildEnv(socketPath: string): Record<string, string> {
|
|
|
376
439
|
return env;
|
|
377
440
|
}
|
|
378
441
|
|
|
442
|
+
/**
|
|
443
|
+
* Assemble this run's mount-set request (design D9).
|
|
444
|
+
*
|
|
445
|
+
* Planning function (Rule 10.4) — pure. It names every path the jail will
|
|
446
|
+
* bind, and reading it is how you answer "why can the hook not see X".
|
|
447
|
+
*
|
|
448
|
+
* The two writable directories are carved out of the module's own read-only
|
|
449
|
+
* tree, which is the one exception to D9's sentence that the tree is bound
|
|
450
|
+
* read-only. `state/` and `screenshots/<run>` both sit INSIDE `<store>/<id>`,
|
|
451
|
+
* and bubblewrap resolves that by order: a later `--bind` wins over an earlier
|
|
452
|
+
* `--ro-bind`. The derivation emits them in that order deliberately.
|
|
453
|
+
*/
|
|
454
|
+
function jailRequest(
|
|
455
|
+
inputs: HookJailInputs,
|
|
456
|
+
context: HookContext,
|
|
457
|
+
socketPath: string,
|
|
458
|
+
): MountSetRequest {
|
|
459
|
+
return {
|
|
460
|
+
modulePath: inputs.modulePath,
|
|
461
|
+
stateDir: context.stateDir,
|
|
462
|
+
screenshotDir: context.screenshotDir,
|
|
463
|
+
socketDir: dirname(socketPath),
|
|
464
|
+
runtimePath: process.execPath,
|
|
465
|
+
runnerPath: HOOK_RUNNER_PATH,
|
|
466
|
+
runtimeModulePaths: runtimeModulePathsFor(HOOK_RUNNER_PATH),
|
|
467
|
+
pathInputs: inputs.pathInputs,
|
|
468
|
+
// Stage 2 only. `remote.ts` still runs inside the hook and needs the key;
|
|
469
|
+
// stage 3 brokers those calls and drops this row (D9, D12). Dropping it
|
|
470
|
+
// early hardens nothing — it stops every hook reaching its own systems.
|
|
471
|
+
sshDir: join(homedir(), '.ssh'),
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* Say what the jail did, once per run, at a level that matches how surprising
|
|
477
|
+
* it is.
|
|
478
|
+
*
|
|
479
|
+
* `skipped` is a warning rather than debug output on purpose. A dropped
|
|
480
|
+
* `/lib64` on arm64 is routine; a dropped contract input means the hook is
|
|
481
|
+
* about to write into the run's private tmpfs and report success over a
|
|
482
|
+
* directory that is discarded when it exits (task 4.2j). Both look identical
|
|
483
|
+
* here, so the line names the paths and lets a reader tell them apart.
|
|
484
|
+
*/
|
|
485
|
+
function reportJail(plan: JailPlan, logger: HookLogger): void {
|
|
486
|
+
if (plan.mode === 'jailed') {
|
|
487
|
+
if (plan.skipped.length > 0) {
|
|
488
|
+
logger.warn(
|
|
489
|
+
`Hook jail: ${plan.skipped.length} mount(s) absent on this host and dropped: ${plan.skipped.join(', ')}`,
|
|
490
|
+
);
|
|
491
|
+
}
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
// Not a warning. D8 is explicit that a steady-state unjailed host (a Mac) is
|
|
495
|
+
// not an event, and a per-invocation warning on a fleet that deploys often
|
|
496
|
+
// is noise that gets filtered. The recorded mode is what raises the alarm.
|
|
497
|
+
if (plan.reason) logger.info(`Hook jail unavailable: ${plan.reason}`);
|
|
498
|
+
}
|
|
499
|
+
|
|
379
500
|
/** Read a piped stream line by line into the logger. */
|
|
380
501
|
async function forwardStream(
|
|
381
502
|
stream: ReadableStream<Uint8Array>,
|
|
@@ -492,6 +613,36 @@ export function checkRequiredCapabilities(
|
|
|
492
613
|
return lines.join(' ');
|
|
493
614
|
}
|
|
494
615
|
|
|
616
|
+
/**
|
|
617
|
+
* The contract's declared path inputs, paired with the values this run got.
|
|
618
|
+
*
|
|
619
|
+
* Policy function (Rule 10.1) — pure.
|
|
620
|
+
*
|
|
621
|
+
* Walks the DECLARATION, never the values. A heuristic over the input names
|
|
622
|
+
* ("does it end in `_dir`?") silently changes what a hook can reach the day
|
|
623
|
+
* somebody adds an input called `workspace`, and the symptom is an `ENOENT` on
|
|
624
|
+
* a path that visibly exists on the box. `ContractField.path` says which
|
|
625
|
+
* fields are paths and at what access, and this reads only that.
|
|
626
|
+
*
|
|
627
|
+
* The corollary is that an undeclared path input contributes nothing to the
|
|
628
|
+
* jail, so the hook cannot reach it. That is the correct outcome and it is why
|
|
629
|
+
* `db_path` had to be declared (celilo#1118, task 4.2h): the contract lying
|
|
630
|
+
* about what a backup hook receives now costs the hook the file.
|
|
631
|
+
*/
|
|
632
|
+
export function declaredPathInputs(
|
|
633
|
+
signature: ContractHookSignature,
|
|
634
|
+
inputs: Record<string, unknown>,
|
|
635
|
+
): DeclaredPathInput[] {
|
|
636
|
+
const declared: DeclaredPathInput[] = [];
|
|
637
|
+
for (const [name, field] of Object.entries(signature.inputs)) {
|
|
638
|
+
if (!field.path) continue;
|
|
639
|
+
const value = inputs[name];
|
|
640
|
+
if (typeof value !== 'string' || value === '') continue;
|
|
641
|
+
declared.push({ name, value, access: field.path.access });
|
|
642
|
+
}
|
|
643
|
+
return declared;
|
|
644
|
+
}
|
|
645
|
+
|
|
495
646
|
/**
|
|
496
647
|
* Find the most recently created screenshot in a directory
|
|
497
648
|
*
|
|
@@ -691,7 +842,11 @@ export async function invokeHook(
|
|
|
691
842
|
// Execute
|
|
692
843
|
try {
|
|
693
844
|
logger.info(`Executing hook: ${hookName}`);
|
|
694
|
-
const outputs = await executeHookScript(scriptPath, context,
|
|
845
|
+
const outputs = await executeHookScript(scriptPath, context, {
|
|
846
|
+
timeoutMs,
|
|
847
|
+
idleTimeoutMs,
|
|
848
|
+
jail: { modulePath, pathInputs: declaredPathInputs(signature, inputs) },
|
|
849
|
+
});
|
|
695
850
|
|
|
696
851
|
// Validate outputs against the contract signature
|
|
697
852
|
const outputError = validateHookOutputs(signature, outputs);
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The live half of the hook jail: a hook that genuinely cannot reach a path
|
|
3
|
+
* (design D9, tasks 4.9 and 4.2j).
|
|
4
|
+
*
|
|
5
|
+
* **This suite needs a real jail and SKIPS without one, loudly.** A Mac has no
|
|
6
|
+
* backend until task 4.8, and neither does a runner with no bubblewrap, so the
|
|
7
|
+
* skip is the common case on a development box. It prints the reason the probe
|
|
8
|
+
* gave rather than passing quietly — CLAUDE.md's whole point about a check that
|
|
9
|
+
* cannot reach its subject is that silence and success look identical.
|
|
10
|
+
*
|
|
11
|
+
* Two properties, and they fail in opposite directions:
|
|
12
|
+
*
|
|
13
|
+
* - **Unreachability, asserted as unreachability.** bubblewrap removes the
|
|
14
|
+
* path (`ENOENT`), `sandbox-exec` denies it (`EPERM`), and both satisfy
|
|
15
|
+
* D9. So the assertion is "the read did not succeed", never a match on an
|
|
16
|
+
* errno — a test pinned to `ENOENT` would go red on macOS for a jail that
|
|
17
|
+
* was working perfectly.
|
|
18
|
+
*
|
|
19
|
+
* - **The staged input survived the tmpfs.** `/tmp` is a fresh tmpfs mounted
|
|
20
|
+
* FIRST, and every staged contract input lives under `os.tmpdir()`. Mount
|
|
21
|
+
* the tmpfs after them and they vanish — and a hook whose `backup_dir` is
|
|
22
|
+
* silently an empty tmpfs directory writes into it, returns success, and
|
|
23
|
+
* produces a backup containing NOTHING. It is found at restore. So this
|
|
24
|
+
* asserts the bytes are on disk in the PARENT afterwards, never that the
|
|
25
|
+
* hook exited zero (task 4.2j).
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { describe, expect, test } from 'bun:test';
|
|
29
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
30
|
+
import { tmpdir } from 'node:os';
|
|
31
|
+
import { join, resolve } from 'node:path';
|
|
32
|
+
import { executeHookScript } from './executor';
|
|
33
|
+
import { detectJailBackend } from './jail';
|
|
34
|
+
import { createCapturingLogger } from './logger';
|
|
35
|
+
import type { HookContext } from './types';
|
|
36
|
+
|
|
37
|
+
const PROBE_HOOK = resolve(__dirname, 'test-fixtures/jail-probe-hook.ts');
|
|
38
|
+
|
|
39
|
+
interface Probe {
|
|
40
|
+
succeeded: boolean;
|
|
41
|
+
detail: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const availability = detectJailBackend();
|
|
45
|
+
const jailed = availability.backend !== 'none';
|
|
46
|
+
|
|
47
|
+
interface Rig {
|
|
48
|
+
outputs: {
|
|
49
|
+
planted_secret: Probe;
|
|
50
|
+
sibling_write: Probe;
|
|
51
|
+
state_write: Probe;
|
|
52
|
+
staged_write: Probe;
|
|
53
|
+
};
|
|
54
|
+
stagedInput: string;
|
|
55
|
+
siblingFile: string;
|
|
56
|
+
cleanup: () => void;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Lay out a module store the way celilo does, run the probe hook against it,
|
|
61
|
+
* and hand back both the hook's report and the paths so the parent can check
|
|
62
|
+
* the disk rather than the report.
|
|
63
|
+
*/
|
|
64
|
+
async function runProbe(): Promise<Rig> {
|
|
65
|
+
const scratch = mkdtempSync(join(tmpdir(), 'celilo-jail-live-'));
|
|
66
|
+
const store = join(scratch, 'modules');
|
|
67
|
+
const modulePath = join(store, 'jail-probe');
|
|
68
|
+
const stateDir = join(modulePath, 'state');
|
|
69
|
+
const screenshotDir = join(modulePath, 'screenshots', 'run');
|
|
70
|
+
mkdirSync(join(modulePath, 'scripts'), { recursive: true });
|
|
71
|
+
mkdirSync(stateDir, { recursive: true });
|
|
72
|
+
mkdirSync(screenshotDir, { recursive: true });
|
|
73
|
+
|
|
74
|
+
// A sibling module, one `..` away from the hook's own tree.
|
|
75
|
+
const sibling = join(store, 'technitium');
|
|
76
|
+
mkdirSync(sibling, { recursive: true });
|
|
77
|
+
const siblingFile = join(sibling, 'trespassed');
|
|
78
|
+
|
|
79
|
+
// celilo's master key, outside the store entirely. Planted rather than real:
|
|
80
|
+
// the operator's key is never read (CLAUDE.md, never test against live data).
|
|
81
|
+
const plantedSecret = join(scratch, 'master.key');
|
|
82
|
+
writeFileSync(plantedSecret, 'not-the-real-key');
|
|
83
|
+
|
|
84
|
+
// A staged contract input, under os.tmpdir() exactly as `stagingDirFor`
|
|
85
|
+
// produces. This is the path the tmpfs would erase.
|
|
86
|
+
const stagedInput = mkdtempSync(join(tmpdir(), 'celilo-jail-staged-'));
|
|
87
|
+
|
|
88
|
+
const { logger } = createCapturingLogger();
|
|
89
|
+
const context: HookContext = {
|
|
90
|
+
config: {
|
|
91
|
+
planted_secret: plantedSecret,
|
|
92
|
+
sibling_file: siblingFile,
|
|
93
|
+
staged_input: stagedInput,
|
|
94
|
+
},
|
|
95
|
+
secrets: {},
|
|
96
|
+
systems: [],
|
|
97
|
+
logger,
|
|
98
|
+
debug: false,
|
|
99
|
+
screenshotDir,
|
|
100
|
+
stateDir,
|
|
101
|
+
capabilities: {},
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const outputs = (await executeHookScript(PROBE_HOOK, context, {
|
|
105
|
+
timeoutMs: 60_000,
|
|
106
|
+
idleTimeoutMs: 60_000,
|
|
107
|
+
jail: {
|
|
108
|
+
modulePath,
|
|
109
|
+
pathInputs: [{ name: 'staged_input', value: stagedInput, access: 'write' }],
|
|
110
|
+
},
|
|
111
|
+
})) as unknown as Rig['outputs'];
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
outputs,
|
|
115
|
+
stagedInput,
|
|
116
|
+
siblingFile,
|
|
117
|
+
cleanup: () => {
|
|
118
|
+
rmSync(scratch, { recursive: true, force: true });
|
|
119
|
+
rmSync(stagedInput, { recursive: true, force: true });
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
describe.skipIf(!jailed)(`the jail is real (backend: ${availability.backend})`, () => {
|
|
125
|
+
test('a hook cannot reach celilo’s master key, or a sibling module', async () => {
|
|
126
|
+
const rig = await runProbe();
|
|
127
|
+
try {
|
|
128
|
+
const { planted_secret, sibling_write, state_write, staged_write } = rig.outputs;
|
|
129
|
+
console.log(['', 'jail probe:', JSON.stringify(rig.outputs, null, 2)].join('\n'));
|
|
130
|
+
|
|
131
|
+
// Unreachability, not an errno. Under bubblewrap the message is ENOENT
|
|
132
|
+
// and under sandbox-exec it is EPERM; asserting either one would make
|
|
133
|
+
// this suite red on a platform whose jail works.
|
|
134
|
+
expect(planted_secret.succeeded).toBe(false);
|
|
135
|
+
expect(sibling_write.succeeded).toBe(false);
|
|
136
|
+
// And the parent's own view: the write did not land by another route.
|
|
137
|
+
expect(existsSync(rig.siblingFile)).toBe(false);
|
|
138
|
+
|
|
139
|
+
// The carve-out. `state/` is inside the read-only module tree and is
|
|
140
|
+
// bound read-write on top of it; if the ordering were wrong this is the
|
|
141
|
+
// assertion that would catch it.
|
|
142
|
+
expect(state_write.succeeded).toBe(true);
|
|
143
|
+
expect(staged_write.succeeded).toBe(true);
|
|
144
|
+
} finally {
|
|
145
|
+
rig.cleanup();
|
|
146
|
+
}
|
|
147
|
+
}, 90_000);
|
|
148
|
+
|
|
149
|
+
test('a staged contract input survives the /tmp tmpfs (task 4.2j)', async () => {
|
|
150
|
+
const rig = await runProbe();
|
|
151
|
+
try {
|
|
152
|
+
// The hook reported success above. That is exactly the signal that is
|
|
153
|
+
// worthless here: a write into a private tmpfs succeeds and is gone.
|
|
154
|
+
// Read the bytes from the parent.
|
|
155
|
+
const produced = join(rig.stagedInput, 'produced');
|
|
156
|
+
expect(existsSync(produced)).toBe(true);
|
|
157
|
+
expect(readFileSync(produced, 'utf-8').length).toBeGreaterThan(0);
|
|
158
|
+
} finally {
|
|
159
|
+
rig.cleanup();
|
|
160
|
+
}
|
|
161
|
+
}, 90_000);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
describe.skipIf(jailed)('no jail on this host', () => {
|
|
165
|
+
test('says so, rather than reporting a pass it did not earn', () => {
|
|
166
|
+
// Not an assertion about the product. It is the line that stops a green
|
|
167
|
+
// run on a Mac reading as "the jail was proven".
|
|
168
|
+
console.log(
|
|
169
|
+
`\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).`,
|
|
170
|
+
);
|
|
171
|
+
expect(availability.backend).toBe('none');
|
|
172
|
+
});
|
|
173
|
+
});
|