@celilo/cli 1.13.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/CELILO_CORE_MODULES.md +1 -1
  2. package/CELILO_SUBSYSTEMS.md +31 -5
  3. package/README.md +0 -2
  4. package/drizzle/0030_drop_module_builds_environment.sql +8 -0
  5. package/drizzle/meta/_journal.json +8 -1
  6. package/package.json +3 -3
  7. package/src/capabilities/public-web-helpers.test.ts +12 -6
  8. package/src/capabilities/public-web-publish.test.ts +42 -13
  9. package/src/capabilities/validation.test.ts +31 -0
  10. package/src/cli/commands/alerts-sweep.ts +3 -0
  11. package/src/cli/commands/console-get-chain.test.ts +96 -0
  12. package/src/cli/commands/console.ts +13 -5
  13. package/src/cli/commands/monitor.ts +15 -2
  14. package/src/cli/commands/notify-config.test.ts +79 -0
  15. package/src/cli/commands/notify-config.ts +13 -2
  16. package/src/cli/commands/system-doctor.test.ts +121 -1
  17. package/src/cli/commands/system-doctor.ts +151 -1
  18. package/src/cli/commands/system-ensure-fleet-key.ts +52 -0
  19. package/src/cli/completion.ts +10 -2
  20. package/src/cli/index.ts +7 -1
  21. package/src/console/closure.test.ts +76 -0
  22. package/src/console/closure.ts +87 -1
  23. package/src/console/control-plane-boundary.test.ts +82 -4
  24. package/src/console/projection.test.ts +63 -1
  25. package/src/console/projection.ts +39 -2
  26. package/src/db/schema.ts +0 -1
  27. package/src/hooks/capability-loader-control-plane-api.test.ts +124 -0
  28. package/src/hooks/capability-loader.ts +81 -10
  29. package/src/hooks/executor.ts +110 -17
  30. package/src/hooks/hook-jail-toolchain-reach.test.ts +224 -0
  31. package/src/hooks/hook-jail-unreachability.test.ts +28 -2
  32. package/src/hooks/hook-protocol.ts +44 -0
  33. package/src/hooks/hook-runner-entry.ts +23 -0
  34. package/src/hooks/hook-runner.ts +10 -0
  35. package/src/hooks/hook-trespass.test.ts +9 -3
  36. package/src/hooks/jail-browser-launch-flags.test.ts +34 -0
  37. package/src/hooks/jail.test.ts +92 -0
  38. package/src/hooks/jail.ts +128 -11
  39. package/src/hooks/mount-set.test.ts +28 -6
  40. package/src/hooks/mount-set.ts +34 -20
  41. package/src/hooks/remote-broker.test.ts +350 -0
  42. package/src/hooks/remote-broker.ts +404 -0
  43. package/src/hooks/run-named-hook.ts +2 -0
  44. package/src/hooks/test-fixtures/jail-probe-hook.ts +14 -1
  45. package/src/hooks/test-fixtures/jail-toolchain-hook.ts +227 -0
  46. package/src/hooks/test-fixtures/remote-bridge-probe.ts +82 -0
  47. package/src/hooks/unjailed-lint.test.ts +251 -0
  48. package/src/hooks/unjailed-lint.ts +395 -0
  49. package/src/manifest/contracts/v1.ts +22 -1
  50. package/src/manifest/validate.ts +25 -4
  51. package/src/module/web-root.ts +35 -0
  52. package/src/policy/module-business-baseline.ts +27 -3
  53. package/src/policy/module-script-scan.test.ts +22 -0
  54. package/src/policy/module-script-scan.ts +92 -1
  55. package/src/policy/no-hand-built-ssh.test.ts +39 -1
  56. package/src/policy/no-module-business-in-core.test.ts +1 -1
  57. package/src/services/alerting/hook-jail.test.ts +66 -0
  58. package/src/services/alerting/hook-jail.ts +70 -0
  59. package/src/services/alerting/run-monitor.test.ts +62 -0
  60. package/src/services/alerting/run-monitor.ts +12 -0
  61. package/src/services/alerting/sweep-runner.test.ts +1 -0
  62. package/src/services/api-principal-enrolment.test.ts +73 -0
  63. package/src/services/api-principal-enrolment.ts +55 -0
  64. package/src/services/backup-create.ts +36 -7
  65. package/src/services/backup-restore.ts +2 -0
  66. package/src/services/celilo-mgmt-hooks.test.ts +38 -79
  67. package/src/services/deploy-ansible.ts +9 -1
  68. package/src/services/fleet-key.test.ts +47 -0
  69. package/src/services/fleet-key.ts +75 -0
  70. package/src/services/health-runner.ts +2 -0
  71. package/src/services/module-build.test.ts +1 -64
  72. package/src/services/module-build.ts +10 -86
  73. package/src/services/module-deploy.ts +20 -0
  74. package/src/services/remote-access.test.ts +139 -0
  75. package/src/services/remote-access.ts +98 -0
  76. package/src/services/restore-from-file.ts +12 -6
  77. package/src/services/static-content-converge.test.ts +338 -0
  78. package/src/services/static-content-converge.ts +299 -0
  79. package/src/services/system-state-stage.test.ts +165 -0
  80. package/src/services/system-state-stage.ts +196 -0
@@ -0,0 +1,124 @@
1
+ /**
2
+ * `control_plane_api` reaches a consuming module, and only a consuming module.
3
+ *
4
+ * The rest of `loadCapabilityFunctions` hands over every capability it can
5
+ * build, declared or not — its own comment says "not just required ones", and
6
+ * `capability-loader-bindings.test.ts` asserts that shape deliberately. That is
7
+ * fine for a capability whose worst outcome is an unused OIDC client.
8
+ *
9
+ * This one mints an SSH principal into celilo's own control plane, so it is
10
+ * gated on the `requires` line instead. These pin both halves, because the
11
+ * failure that matters is silent in both directions: a console that declared it
12
+ * and got nothing fails at install with "no provider is loaded" for a capability
13
+ * no module ever provides, and a module that declared nothing and got it anyway
14
+ * holds control-plane read that no manifest review would have shown.
15
+ */
16
+
17
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
18
+ import type { HookLogger } from '@celilo/capabilities';
19
+ import type { DbClient } from '../db/client';
20
+ import { cleanupTestDatabase, setupTestDatabase } from '../test-utils/database';
21
+ import { loadCapabilityFunctions } from './capability-loader';
22
+
23
+ const noopLogger: HookLogger = {
24
+ info() {},
25
+ warn() {},
26
+ error() {},
27
+ success() {},
28
+ };
29
+
30
+ function installModule(db: DbClient, moduleId: string, manifest: Record<string, unknown>): void {
31
+ db.$client.run(
32
+ `INSERT INTO modules (id, name, version, source_path, manifest_data) VALUES (?, ?, '1.0.0', '/tmp/${moduleId}', ?)`,
33
+ [moduleId, moduleId, JSON.stringify(manifest)],
34
+ );
35
+ }
36
+
37
+ const DECLARES = {
38
+ requires: { capabilities: [{ name: 'control_plane_api', version: '1.0.0' }] },
39
+ };
40
+
41
+ describe('control_plane_api injection', () => {
42
+ let db: DbClient;
43
+
44
+ beforeEach(async () => {
45
+ db = await setupTestDatabase();
46
+ });
47
+
48
+ afterEach(async () => {
49
+ await cleanupTestDatabase(db);
50
+ });
51
+
52
+ test('a module that requires it gets it, with no provider module deployed', async () => {
53
+ // The point of the whole decision: nothing provides this capability, and it
54
+ // is still there. A test that first deployed a provider would be measuring
55
+ // the ordinary path.
56
+ installModule(db, 'celilo-web-console', DECLARES);
57
+
58
+ const capabilities = await loadCapabilityFunctions('celilo-web-console', db, noopLogger);
59
+
60
+ expect(capabilities.control_plane_api).toBeTruthy();
61
+ const api = capabilities.control_plane_api as Record<string, unknown>;
62
+ expect(typeof api.enrol_principal).toBe('function');
63
+ expect(typeof api.revoke_principal).toBe('function');
64
+ });
65
+
66
+ test('a module that declares it as optional gets it too', async () => {
67
+ // `optional` is a declaration a reviewer reads on the same line of the same
68
+ // file. Treating it as no declaration would fail the module at runtime for a
69
+ // capability its manifest names.
70
+ installModule(db, 'someday-console', {
71
+ optional: { capabilities: [{ name: 'control_plane_api', version: '1.0.0' }] },
72
+ });
73
+
74
+ const capabilities = await loadCapabilityFunctions('someday-console', db, noopLogger);
75
+
76
+ expect(capabilities.control_plane_api).toBeTruthy();
77
+ });
78
+
79
+ test('a module that declares nothing does NOT get it', async () => {
80
+ installModule(db, 'hello-foo', { requires: { capabilities: [] } });
81
+
82
+ const capabilities = await loadCapabilityFunctions('hello-foo', db, noopLogger);
83
+
84
+ expect(capabilities.control_plane_api).toBeUndefined();
85
+ });
86
+
87
+ test('a module that requires something ELSE does NOT get it', async () => {
88
+ // The near miss: a manifest with a populated `requires` block is the shape a
89
+ // too-loose check ("does this module require anything?") would wave through.
90
+ installModule(db, 'hello-bar', {
91
+ requires: { capabilities: [{ name: 'idp', version: '1.0.0' }] },
92
+ });
93
+
94
+ const capabilities = await loadCapabilityFunctions('hello-bar', db, noopLogger);
95
+
96
+ expect(capabilities.control_plane_api).toBeUndefined();
97
+ });
98
+
99
+ test('a module celilo has no record of does NOT get it', async () => {
100
+ // A hook can run for a module id with no row (a removal path, a typo). The
101
+ // manifest lookup returns nothing, and "no manifest" must read as "declared
102
+ // nothing" rather than throwing or defaulting open.
103
+ const capabilities = await loadCapabilityFunctions('never-imported', db, noopLogger);
104
+
105
+ expect(capabilities.control_plane_api).toBeUndefined();
106
+ });
107
+
108
+ test('the injected object is scoped to the module it was built for', async () => {
109
+ // Scoping is what stops a module rotating a neighbour's key. It is asserted
110
+ // through the loader rather than only on `buildControlPlaneApi`, because the
111
+ // loader is where the wrong argument (a provider id, a hook name) would be
112
+ // passed.
113
+ installModule(db, 'hello-foo', DECLARES);
114
+
115
+ const capabilities = await loadCapabilityFunctions('hello-foo', db, noopLogger);
116
+ const api = capabilities.control_plane_api as {
117
+ revoke_principal(request: { name: string }): Promise<unknown>;
118
+ };
119
+
120
+ await expect(api.revoke_principal({ name: 'celilo-web-console' })).rejects.toThrow(
121
+ /only revoke its own principal/,
122
+ );
123
+ });
124
+ });
@@ -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,
@@ -574,6 +605,20 @@ export async function loadCapabilityFunctions(
574
605
  caddyModuleId: provider.moduleId,
575
606
  dnsManagedDomains,
576
607
  dnsRegistrarModuleId,
608
+ // Design D10: the bytes move through the provider's Ansible converge,
609
+ // not a hand-built ssh tar pipe. Core implements it over
610
+ // executeAnsible against the provider's generated project (task 4.3);
611
+ // awaited with everything else, so a publish returns only once the
612
+ // host matches. A failure here fails the deploy loudly — a publish
613
+ // that reports ready while the host never received the bytes is the
614
+ // exact "served but silently unreachable" anti-pattern.
615
+ convergeStaticContent: async () => {
616
+ const { convergeStaticContent } = await import('../services/static-content-converge');
617
+ const result = await convergeStaticContent(db, provider.moduleId);
618
+ if (!result.success) {
619
+ throw new Error(result.error ?? 'static-content converge failed');
620
+ }
621
+ },
577
622
  // ISS-0035: register_route/unregister_routes emit this coarse signal
578
623
  // instead of SSHing caddy; the caddy provider's reconcile_routes
579
624
  // subscription re-renders the Caddyfile from web_routes. We await the
@@ -641,6 +686,30 @@ export async function loadCapabilityFunctions(
641
686
  debugLog('public_web: not registered in DB, skipping');
642
687
  }
643
688
 
689
+ // Framework-granted, so unlike everything above there is no provider row to
690
+ // look up and no script to import — celilo IS the management server whose
691
+ // principals these are (web-ui-console D7b).
692
+ //
693
+ // Gated on the DECLARATION, which the rest of this function deliberately is
694
+ // not: the loop injects every capability it can build "not just required
695
+ // ones", so a module that never asked still gets `idp` and `firewall`. That is
696
+ // fine for a capability whose worst outcome is an unused OIDC client. This one
697
+ // mints an SSH principal into celilo's own control plane, so the `requires`
698
+ // line is the authorization and a module that did not write one does not get
699
+ // the object at all.
700
+ if (consumerDeclares(db, consumingModuleId, 'control_plane_api')) {
701
+ // Wrapped like every other capability, so an enrolment that fails mid-deploy
702
+ // leaves a `✗ control_plane_api.enrol_principal` in the hook log rather than
703
+ // only a thrown error further up. The `defineCapabilityFunction` path wraps
704
+ // itself; a framework-built table has to be wrapped here.
705
+ result.control_plane_api = wrapWithLogging(
706
+ buildControlPlaneApi(consumingModuleId),
707
+ logger,
708
+ 'control_plane_api',
709
+ );
710
+ debugLog(`control_plane_api: framework-granted, injected for ${consumingModuleId}`);
711
+ }
712
+
644
713
  // celilo#1072: the CALL is the binding, not the resolution. Everything above
645
714
  // is injected whether or not the consumer declared it — the loop's own
646
715
  // comment says "not just required ones" — so recording what was resolved
@@ -1104,20 +1173,19 @@ async function buildFirewallChain(
1104
1173
  // attributable. Only the layers that render their own ruleset receive it.
1105
1174
  const trustedSourceStore = buildTrustedSourceStore(db, consumingModuleId);
1106
1175
 
1107
- // Find the provider with has_external (the leaf — has direct internet access)
1108
- const hasExternal = allProviders.find((p) => {
1109
- const data = (typeof p.data === 'string' ? JSON.parse(p.data) : p.data) as Record<
1110
- string,
1111
- unknown
1112
- >;
1113
- return data.has_external === true;
1114
- });
1176
+ // The delegation order, most downstream first. Shared with the console's
1177
+ // closure so the wiring and the picture cannot disagree about which firewall
1178
+ // stands on which — see `orderFirewallChain`.
1179
+ const chainOrder = orderFirewallChain(allProviders);
1115
1180
 
1116
- if (!hasExternal) {
1181
+ if (chainOrder.length === 0) {
1117
1182
  debugLog('firewall chain: no provider with external interface found');
1118
1183
  return { chain: null, self: null };
1119
1184
  }
1120
1185
 
1186
+ // The leaf is the upstream end: the provider with the direct internet leg.
1187
+ const hasExternal = chainOrder[chainOrder.length - 1] as (typeof chainOrder)[number];
1188
+
1121
1189
  // Build the leaf (external) firewall first
1122
1190
  const leafModule = db.select().from(modules).where(eq(modules.id, hasExternal.moduleId)).get();
1123
1191
  if (!leafModule) return { chain: null, self: null };
@@ -1173,7 +1241,10 @@ async function buildFirewallChain(
1173
1241
  // Build downstream providers, wiring each to the upstream. iptables
1174
1242
  // remains a legacy factory because its second arg (upstreamFirewall)
1175
1243
  // doesn't fit defineCapabilityFunction's single-context shape.
1176
- const downstream = allProviders.filter((p) => p.moduleId !== hasExternal.moduleId);
1244
+ // Back into wiring order: each layer is built taking the previous one as its
1245
+ // upstream, so the build runs from the leaf outwards while the chain reads
1246
+ // from the consumer inwards.
1247
+ const downstream = chainOrder.slice(0, -1).reverse();
1177
1248
 
1178
1249
  // Each layer as its OWN provider sees it, so `on_consumer_removed` converges
1179
1250
  // the firewall that declares the hook rather than whichever layer a consumer
@@ -31,17 +31,19 @@
31
31
  * exists (a Mac today, task 4.8) the hook runs unjailed and the mode is
32
32
  * RECORDED, so a host that quietly stops jailing is visible rather than silent.
33
33
  *
34
- * Hooks still do NOT execute on the target machine. One that needs to touch a
35
- * target initiates SSH outbound itself, so anything it depends on (chromium,
36
- * system binaries, credentials) must be available on the celilo CLI host.
37
- * Stage 3 scopes that reachability and has not landed, which is why `~/.ssh`
38
- * is still bound into the jail read-only.
34
+ * Hooks still do NOT execute on the target machine — a remote operation is
35
+ * initiated from the celilo host. **Stage 3 has landed and scopes that
36
+ * reachability by the credential (design D12):** `~/.ssh` is not bound into
37
+ * the jail, so a hand-built `ssh` cannot authenticate, and the remote
38
+ * primitives cross to the remote-ops broker (`remote-broker.ts`), which holds
39
+ * the key and checks every target against the policy in
40
+ * `services/remote-access.ts` — the module's own systems, or the module's own
41
+ * credential.
39
42
  *
40
43
  * Execution function (Rule 10.1) - performs side effects (script execution)
41
44
  */
42
45
 
43
46
  import { existsSync, mkdirSync, readdirSync, rmdirSync, statSync } from 'node:fs';
44
- import { homedir } from 'node:os';
45
47
  import { dirname, join, resolve } from 'node:path';
46
48
  import {
47
49
  type DeployedSystem,
@@ -59,8 +61,10 @@ import { isPrivilegedCapability } from '../manifest/validate';
59
61
  import { pruneModuleArtifacts } from './artifact-retention';
60
62
  import { startBroker } from './broker';
61
63
  import {
64
+ HOOK_MOUNT_SET_ENV,
62
65
  HOOK_PROTOCOL_VERSION,
63
66
  HOOK_PROTOCOL_VERSION_ENV,
67
+ HOOK_REMOTE_SOCKET_ENV,
64
68
  HOOK_SOCKET_ENV,
65
69
  createLineReader,
66
70
  deserializeError,
@@ -74,8 +78,15 @@ import {
74
78
  recordJailMode,
75
79
  runtimeModulePathsFor,
76
80
  } from './jail';
77
- import { type DeclaredPathInput, type MountSetRequest, deriveMountSet } from './mount-set';
81
+ import {
82
+ type DeclaredPathInput,
83
+ type MountSet,
84
+ type MountSetRequest,
85
+ deriveMountSet,
86
+ } from './mount-set';
87
+ import { type RemoteAccessPolicy, startRemoteBroker } from './remote-broker';
78
88
  import type { HookContext, HookDefinition, HookLogger, HookResult } from './types';
89
+ import { mountSetEnvValue } from './unjailed-lint';
79
90
 
80
91
  /** Default total timeout: 60 seconds */
81
92
  const DEFAULT_TIMEOUT_MS = 60_000;
@@ -160,6 +171,14 @@ const FORWARDED_ENV = [
160
171
 
161
172
  /** The shim celilo spawns. Resolved from here so an npm install finds it too. */
162
173
  const HOOK_RUNNER_PATH = join(import.meta.dir, 'hook-runner.ts');
174
+ /**
175
+ * What celilo actually SPAWNS: the entry that installs the advisory lint
176
+ * before the runner shim's own import graph can load `node:fs` (task 4.7).
177
+ * The runner path above is still what the mount set binds — the entry and the
178
+ * shim live in the same directory and resolve through the same
179
+ * `node_modules`.
180
+ */
181
+ const HOOK_RUNNER_SPAWN_PATH = join(import.meta.dir, 'hook-runner-entry.ts');
163
182
 
164
183
  /**
165
184
  * Validate hook inputs against a contract signature.
@@ -265,6 +284,13 @@ export interface ExecuteHookOptions {
265
284
  idleTimeoutMs?: number;
266
285
  /** What to jail this run with. Absent runs the hook unjailed. */
267
286
  jail?: HookJailInputs;
287
+ /**
288
+ * D12's target policy for this run's remote operations, built by the caller
289
+ * (`services/remote-access.ts`) so the executor stays decoupled from the
290
+ * database the same way it is for `systems`. Absent means every
291
+ * fleet-credential operation is REFUSED, naming the gap (Rule 6.4).
292
+ */
293
+ remoteAccess?: RemoteAccessPolicy;
268
294
  }
269
295
 
270
296
  /**
@@ -308,12 +334,27 @@ export async function executeHookScript(
308
334
  logger,
309
335
  onActivity: markActive,
310
336
  });
337
+ let remoteBroker: Awaited<ReturnType<typeof startRemoteBroker>> | undefined;
311
338
 
312
339
  try {
340
+ // The remote-ops socket lives beside the capability socket, so the jail's
341
+ // one socket-directory mount covers both channels (design D12).
342
+ const roots = remoteRoots(context, options.jail);
343
+ remoteBroker = await startRemoteBroker({
344
+ socketDir: dirname(broker.socketPath),
345
+ policy: options.remoteAccess,
346
+ readableRoots: roots.readable,
347
+ writableRoots: roots.writable,
348
+ logger,
349
+ onActivity: markActive,
350
+ });
351
+
352
+ const mountSet = options.jail
353
+ ? deriveMountSet(realpathRequest(jailRequest(options.jail, context, broker.socketPath)))
354
+ : undefined;
313
355
  const jail = planJailedSpawn(
314
- [process.execPath, HOOK_RUNNER_PATH],
315
- options.jail &&
316
- deriveMountSet(realpathRequest(jailRequest(options.jail, context, broker.socketPath))),
356
+ [process.execPath, HOOK_RUNNER_SPAWN_PATH],
357
+ mountSet,
317
358
  detectJailBackend(),
318
359
  jailPolicy(),
319
360
  );
@@ -326,7 +367,13 @@ export async function executeHookScript(
326
367
 
327
368
  const child = Bun.spawn({
328
369
  cmd: [...jail.cmd],
329
- env: hookChildEnv(broker.socketPath),
370
+ env: hookChildEnv(
371
+ broker.socketPath,
372
+ remoteBroker.socketPath,
373
+ // The lint rides only on an unjailed run — its presence in the child's
374
+ // environment is the shim's signal to install it (task 4.7).
375
+ jail.mode === 'unjailed' ? mountSet : undefined,
376
+ ),
330
377
  stdout: 'pipe',
331
378
  stderr: 'pipe',
332
379
  });
@@ -346,8 +393,10 @@ export async function executeHookScript(
346
393
  killedFor = reason;
347
394
  // Refuse capability calls FIRST. A hook between `kill` and its own death
348
395
  // can still have a call in flight, and answering it is exactly the
349
- // failure this replaces.
396
+ // failure this replaces. The remote channel refuses for the same reason:
397
+ // a killed hook must not go on reaching systems (celilo#1003, D12).
350
398
  broker.stop();
399
+ remoteBroker?.stop();
351
400
  child.kill('SIGTERM');
352
401
  setTimeout(() => child.kill('SIGKILL'), SIGKILL_GRACE_MS).unref();
353
402
  };
@@ -398,10 +447,41 @@ export async function executeHookScript(
398
447
  if (!outcome.ok) throw deserializeError(outcome.error);
399
448
  return outcome.outputs;
400
449
  } finally {
450
+ // Remote first: its socket file lives inside the directory the capability
451
+ // broker's close removes.
452
+ remoteBroker?.close();
401
453
  broker.close();
402
454
  }
403
455
  }
404
456
 
457
+ /**
458
+ * The local directories this run's stream primitives may touch (design D12).
459
+ *
460
+ * Derived from the same inputs as the jail's mount set, which is what keeps
461
+ * the two views from drifting: everything writable here is writable there,
462
+ * and a path outside these roots is a path the jailed hook could not see
463
+ * anyway — the broker writing to it would produce a file the hook cannot
464
+ * read, reported as success.
465
+ *
466
+ * Planning function (Rule 10.4) — pure.
467
+ */
468
+ function remoteRoots(
469
+ context: HookContext,
470
+ jail: HookJailInputs | undefined,
471
+ ): { readable: string[]; writable: string[] } {
472
+ const writable: string[] = [context.stateDir, context.screenshotDir];
473
+ const readable: string[] = [];
474
+ if (jail) {
475
+ readable.push(jail.modulePath);
476
+ writable.push(join(jail.modulePath, 'generated'));
477
+ for (const input of jail.pathInputs) {
478
+ (input.access === 'write' ? writable : readable).push(input.value);
479
+ }
480
+ }
481
+ // Whatever a hook may write, it may also read back.
482
+ return { readable: [...readable, ...writable], writable };
483
+ }
484
+
405
485
  /**
406
486
  * The child's environment, built from an allow-list (design D5).
407
487
  *
@@ -422,7 +502,11 @@ export async function executeHookScript(
422
502
  *
423
503
  * Planning function (Rule 10.4) — pure.
424
504
  */
425
- export function hookChildEnv(socketPath: string): Record<string, string> {
505
+ export function hookChildEnv(
506
+ socketPath: string,
507
+ remoteSocketPath: string,
508
+ mountSet: MountSet | undefined,
509
+ ): Record<string, string> {
426
510
  const env: Record<string, string> = {};
427
511
 
428
512
  for (const name of FORWARDED_ENV) {
@@ -431,6 +515,10 @@ export function hookChildEnv(socketPath: string): Record<string, string> {
431
515
  }
432
516
 
433
517
  env[HOOK_SOCKET_ENV] = socketPath;
518
+ env[HOOK_REMOTE_SOCKET_ENV] = remoteSocketPath;
519
+ // The unjailed advisory lint's input (task 4.7). Absent when jailed — the
520
+ // shim installs the lint when and only when this variable is set.
521
+ if (mountSet) env[HOOK_MOUNT_SET_ENV] = mountSetEnvValue(mountSet);
434
522
  env[HOOK_PROTOCOL_VERSION_ENV] = String(HOOK_PROTOCOL_VERSION);
435
523
  // Forwarded when the operator set it, not synthesised from `debug` — a hook
436
524
  // reads the flag off `ctx.debug`, which crosses in the context frame.
@@ -465,10 +553,6 @@ function jailRequest(
465
553
  runnerPath: HOOK_RUNNER_PATH,
466
554
  runtimeModulePaths: runtimeModulePathsFor(HOOK_RUNNER_PATH),
467
555
  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
556
  };
473
557
  }
474
558
 
@@ -544,6 +628,14 @@ export interface InvokeHookOptions {
544
628
  * manifest hook's own `timeout`, being the more specific declaration.
545
629
  */
546
630
  timeoutMs?: number;
631
+ /**
632
+ * D12's target policy for the hook's remote operations, built by the caller
633
+ * via `remoteAccessPolicy(moduleId, db)` — the same caller-loads-it pattern
634
+ * as `systems`, and for the same reason: the executor stays decoupled from
635
+ * the database. Absent means every fleet-credential remote operation is
636
+ * refused (Rule 6.4).
637
+ */
638
+ remoteAccess?: RemoteAccessPolicy;
547
639
  }
548
640
 
549
641
  /**
@@ -846,6 +938,7 @@ export async function invokeHook(
846
938
  timeoutMs,
847
939
  idleTimeoutMs,
848
940
  jail: { modulePath, pathInputs: declaredPathInputs(signature, inputs) },
941
+ remoteAccess: options.remoteAccess,
849
942
  });
850
943
 
851
944
  // Validate outputs against the contract signature