@celilo/cli 1.14.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/CELILO_CORE_MODULES.md +2 -2
  2. package/CELILO_SUBSYSTEMS.md +26 -3
  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-publish.test.ts +18 -0
  8. package/src/cli/commands/alerts-sweep.ts +3 -0
  9. package/src/cli/commands/monitor.ts +15 -2
  10. package/src/cli/commands/system-doctor.test.ts +121 -1
  11. package/src/cli/commands/system-doctor.ts +151 -1
  12. package/src/cli/completion.ts +9 -2
  13. package/src/cli/index.ts +1 -1
  14. package/src/console/control-plane-boundary.test.ts +82 -4
  15. package/src/db/schema.ts +0 -1
  16. package/src/hooks/capability-loader.ts +15 -2
  17. package/src/hooks/executor.ts +116 -17
  18. package/src/hooks/hook-jail-toolchain-reach.test.ts +273 -0
  19. package/src/hooks/hook-jail-unreachability.test.ts +77 -26
  20. package/src/hooks/hook-protocol.ts +44 -0
  21. package/src/hooks/hook-runner-entry.ts +23 -0
  22. package/src/hooks/hook-runner.ts +10 -0
  23. package/src/hooks/hook-trespass.test.ts +74 -12
  24. package/src/hooks/jail-browser-launch-flags.test.ts +34 -0
  25. package/src/hooks/jail.test.ts +92 -0
  26. package/src/hooks/jail.ts +304 -32
  27. package/src/hooks/mount-set.test.ts +116 -7
  28. package/src/hooks/mount-set.ts +189 -20
  29. package/src/hooks/remote-broker.test.ts +350 -0
  30. package/src/hooks/remote-broker.ts +404 -0
  31. package/src/hooks/run-named-hook.ts +2 -0
  32. package/src/hooks/test-fixtures/jail-probe-hook.ts +14 -1
  33. package/src/hooks/test-fixtures/jail-toolchain-hook.ts +227 -0
  34. package/src/hooks/test-fixtures/remote-bridge-probe.ts +82 -0
  35. package/src/hooks/unjailed-lint.test.ts +254 -0
  36. package/src/hooks/unjailed-lint.ts +395 -0
  37. package/src/policy/module-business-baseline.ts +13 -1
  38. package/src/policy/module-script-scan.ts +60 -1
  39. package/src/policy/no-hand-built-ssh.test.ts +39 -1
  40. package/src/policy/no-module-business-in-core.test.ts +1 -1
  41. package/src/services/alerting/coverage-source.test.ts +86 -0
  42. package/src/services/alerting/coverage-source.ts +11 -1
  43. package/src/services/alerting/hook-jail.test.ts +66 -0
  44. package/src/services/alerting/hook-jail.ts +70 -0
  45. package/src/services/alerting/run-monitor.test.ts +62 -0
  46. package/src/services/alerting/run-monitor.ts +12 -0
  47. package/src/services/alerting/sweep-runner.test.ts +1 -0
  48. package/src/services/backup-create.ts +3 -0
  49. package/src/services/backup-restore.ts +2 -0
  50. package/src/services/control-plane-bootstrap.test.ts +177 -0
  51. package/src/services/control-plane-bootstrap.ts +176 -0
  52. package/src/services/control-plane-health.test.ts +66 -0
  53. package/src/services/control-plane-health.ts +67 -0
  54. package/src/services/deploy-ansible.ts +9 -1
  55. package/src/services/deployed-systems.ts +12 -0
  56. package/src/services/dns-discovery.test.ts +93 -0
  57. package/src/services/dns-discovery.ts +92 -0
  58. package/src/services/fleet-checks.ts +6 -2
  59. package/src/services/health-runner.ts +36 -3
  60. package/src/services/module-build.test.ts +1 -64
  61. package/src/services/module-build.ts +10 -86
  62. package/src/services/module-deploy.ts +71 -0
  63. package/src/services/remote-access.test.ts +223 -0
  64. package/src/services/remote-access.ts +149 -0
  65. package/src/services/restore-from-file.ts +6 -1
  66. package/src/services/static-content-converge.test.ts +338 -0
  67. package/src/services/static-content-converge.ts +299 -0
@@ -32,13 +32,27 @@
32
32
  import { spawnSync } from 'node:child_process';
33
33
  import { existsSync, readFileSync, statSync } from 'node:fs';
34
34
  import { createRequire } from 'node:module';
35
+ import { hostname } from 'node:os';
35
36
  import { dirname, join, resolve } from 'node:path';
36
37
  import { resolveBrowser } from '@celilo/capabilities';
37
38
  import { defineEvents, openBus } from '@celilo/event-bus';
38
39
  import cliPkg from '../../../package.json' with { type: 'json' };
39
40
  import { getDbPath, getEventBusPath } from '../../config/paths';
40
41
  import { getDb } from '../../db/client';
42
+ import {
43
+ JAIL_OFF_REASON,
44
+ type JailAvailability,
45
+ type JailModeRecord,
46
+ type JailPolicy,
47
+ SANDBOX_EXEC_AUTO_DEFERRED_REASON,
48
+ autoJailDefers,
49
+ detectJailBackend,
50
+ jailPolicy,
51
+ readJailMode,
52
+ } from '../../hooks/jail';
41
53
  import type { ModuleManifest } from '../../manifest/schema';
54
+ import { HOOK_JAIL_CHECK } from '../../services/alerting/hook-jail';
55
+ import { findMonitorByTarget } from '../../services/alerting/monitors';
42
56
  import {
43
57
  type FleetFinding,
44
58
  type FleetFindingStatus,
@@ -336,6 +350,112 @@ function provisionedBrowserFlavor(): string | null {
336
350
  }
337
351
  }
338
352
 
353
+ export interface HookExecutionInput {
354
+ availability: JailAvailability;
355
+ policy: JailPolicy;
356
+ /** What the last real module-hook run recorded, if any. */
357
+ record: JailModeRecord | undefined;
358
+ host: string;
359
+ /**
360
+ * Whether a `hook_jail` monitor exists. `null` when there is no celilo DB to
361
+ * ask (a dev box), which also suppresses the add-the-monitor hint.
362
+ */
363
+ monitored: boolean | null;
364
+ }
365
+
366
+ /**
367
+ * The hook-execution section (hook-process-boundary task 4.6).
368
+ *
369
+ * Reports design D8's three states the way the design assigns them: jailed is
370
+ * a ✔ and nothing more; unjailed-and-always-was is reported with the reason in
371
+ * one actionable sentence and is a warning, not an event; a host that USED to
372
+ * jail and has stopped is a failure, and the self-monitor (`celilo monitor add
373
+ * hook_jail`) is what turns it into an alert.
374
+ *
375
+ * The headline is the LIVE answer — the probe runs bubblewrap the way a real
376
+ * spawn would — while the recorded mode says what the last hook actually did.
377
+ * They disagree exactly when the host changed since the last hook ran, which
378
+ * is worth seeing, not reconciling away.
379
+ *
380
+ * Pure so every branch is testable on a host with no jail (Rule 10.4); the
381
+ * caller injects what the machine says.
382
+ */
383
+ export function renderHookExecutionSection(input: HookExecutionInput): {
384
+ lines: string[];
385
+ failCount: number;
386
+ warnCount: number;
387
+ } {
388
+ const { availability, policy, record, host, monitored } = input;
389
+ const lines: string[] = ['Hook execution'];
390
+ let failCount = 0;
391
+ let warnCount = 0;
392
+
393
+ // ce-29z: auto defers on sandbox-exec, so the doctor must not report a jail
394
+ // the executor will not build. One predicate, two consumers.
395
+ const autoDeferred = policy === 'auto' && autoJailDefers(availability);
396
+ const liveJailed = policy !== 'off' && availability.backend !== 'none' && !autoDeferred;
397
+ if (policy === 'required' && availability.backend === 'none') {
398
+ lines.push(
399
+ ` ${ANSI.red}✗${ANSI.reset} CELILO_HOOK_JAIL=required and no jail is available — every module hook on this host fails rather than run unjailed`,
400
+ );
401
+ lines.push(` ${ANSI.dim}${availability.reason ?? 'no reason was recorded'}${ANSI.reset}`);
402
+ failCount++;
403
+ } else if (policy === 'off') {
404
+ lines.push(` ${ANSI.yellow}⚠${ANSI.reset} hooks run unjailed — ${JAIL_OFF_REASON}`);
405
+ warnCount++;
406
+ } else if (availability.backend === 'none') {
407
+ lines.push(
408
+ ` ${ANSI.yellow}⚠${ANSI.reset} hooks run unjailed — ${availability.reason ?? 'no reason was recorded'}`,
409
+ );
410
+ warnCount++;
411
+ } else if (autoDeferred) {
412
+ lines.push(
413
+ ` ${ANSI.yellow}⚠${ANSI.reset} hooks run unjailed — ${SANDBOX_EXEC_AUTO_DEFERRED_REASON}`,
414
+ );
415
+ warnCount++;
416
+ } else {
417
+ lines.push(` ${ANSI.green}✔${ANSI.reset} hooks run jailed (${availability.backend})`);
418
+ }
419
+
420
+ const regressed =
421
+ record && record.host === host && record.mode === 'unjailed' && record.lastJailed;
422
+ if (regressed && record.lastJailed) {
423
+ lines.push(
424
+ ` ${ANSI.red}✗${ANSI.reset} this host ran hooks jailed (${record.lastJailed.backend}) until ${record.lastJailed.recordedAt} and has stopped`,
425
+ );
426
+ if (record.reason) lines.push(` ${ANSI.dim}${record.reason}${ANSI.reset}`);
427
+ failCount++;
428
+ if (monitored === false) {
429
+ lines.push(
430
+ ` ${ANSI.dim}→ nothing alerts on this — celilo monitor add ${HOOK_JAIL_CHECK}${ANSI.reset}`,
431
+ );
432
+ }
433
+ } else if (!record) {
434
+ lines.push(
435
+ ` ${ANSI.dim}no execution mode recorded yet — the first real module-hook run writes it${ANSI.reset}`,
436
+ );
437
+ } else if (record.host !== host) {
438
+ lines.push(
439
+ ` ${ANSI.dim}last recorded mode belongs to host "${record.host}" (this data directory moved) — the next hook run re-records${ANSI.reset}`,
440
+ );
441
+ } else {
442
+ const backend = record.mode === 'jailed' ? ` (${record.backend})` : '';
443
+ lines.push(
444
+ ` ${ANSI.dim}last hook ran ${record.mode}${backend} at ${record.recordedAt}${ANSI.reset}`,
445
+ );
446
+ }
447
+
448
+ // The moment this hint pays for itself is BEFORE a regression: the monitor
449
+ // has to exist for the transition to page anyone.
450
+ if (liveJailed && monitored === false) {
451
+ lines.push(
452
+ ` ${ANSI.dim}→ a host that stops jailing alerts only if monitored: celilo monitor add ${HOOK_JAIL_CHECK}${ANSI.reset}`,
453
+ );
454
+ }
455
+
456
+ return { lines, failCount, warnCount };
457
+ }
458
+
339
459
  /**
340
460
  * mtime (ms) of the installed dispatcher code (`@celilo/event-bus`
341
461
  * package.json). The fleet dispatcher check compares this against the
@@ -616,6 +736,33 @@ export async function handleSystemDoctor(
616
736
  lines.push(...prereqResult.lines);
617
737
  lines.push('');
618
738
 
739
+ // Hook execution mode (hook-process-boundary task 4.6). The policy read
740
+ // throws on a typo'd CELILO_HOOK_JAIL — report that rather than crash the
741
+ // doctor, since the same throw is about to fail every hook invocation too.
742
+ let hookExec: { lines: string[]; failCount: number; warnCount: number };
743
+ try {
744
+ hookExec = renderHookExecutionSection({
745
+ availability: detectJailBackend(),
746
+ policy: jailPolicy(),
747
+ record: readJailMode(),
748
+ host: hostname(),
749
+ monitored: existsSync(getDbPath())
750
+ ? findMonitorByTarget(getDb(), HOOK_JAIL_CHECK) !== undefined
751
+ : null,
752
+ });
753
+ } catch (error) {
754
+ hookExec = {
755
+ lines: [
756
+ 'Hook execution',
757
+ ` ${ANSI.red}✗${ANSI.reset} ${error instanceof Error ? error.message : String(error)}`,
758
+ ],
759
+ failCount: 1,
760
+ warnCount: 0,
761
+ };
762
+ }
763
+ lines.push(...hookExec.lines);
764
+ lines.push('');
765
+
619
766
  const workspaceRoot = findWorkspaceRoot(process.cwd());
620
767
  const workspaceVersions = workspaceRoot ? collectWorkspaceVersions(workspaceRoot) : [];
621
768
  const workspaceMap = new Map(workspaceVersions.map((w) => [w.name, w]));
@@ -748,6 +895,9 @@ export async function handleSystemDoctor(
748
895
  }
749
896
  if (driftCount > 0) problems.push(`${driftCount} package(s) behind workspace`);
750
897
  if (unresolvedCount > 0) problems.push(`${unresolvedCount} unresolved`);
898
+ if (hookExec.failCount > 0) {
899
+ problems.push(`${hookExec.failCount} hook-execution problem(s)`);
900
+ }
751
901
  if (fleet.failCount > 0) problems.push(`${fleet.failCount} fleet check(s) failing`);
752
902
  if (aspectFailCount > 0) problems.push(`${aspectFailCount} system(s) missing a fleet aspect`);
753
903
 
@@ -760,7 +910,7 @@ export async function handleSystemDoctor(
760
910
  }
761
911
 
762
912
  // Fleet warnings don't fail the run, but they shouldn't read as a clean bill.
763
- const warnTotal = fleet.warnCount + aspectWarnCount;
913
+ const warnTotal = fleet.warnCount + aspectWarnCount + hookExec.warnCount;
764
914
  if (warnTotal > 0) {
765
915
  lines.push(`${ANSI.yellow}OK with warnings${ANSI.reset} — ${warnTotal} warning(s); see above`);
766
916
  } else {
@@ -8,6 +8,8 @@ import { getDb } from '../db/client';
8
8
  import { capabilities, modules } from '../db/schema';
9
9
  import type { ModuleManifest } from '../manifest/schema';
10
10
  import { SCHEDULABLE_BUILTIN_CHECKS } from '../services/alerting/builtin-source';
11
+ import { HEALTH_COVERAGE_CHECK } from '../services/alerting/health-coverage';
12
+ import { HOOK_JAIL_CHECK } from '../services/alerting/hook-jail';
11
13
  import { listPrincipals } from '../services/api-access';
12
14
  import { listBackups } from '../services/backup-metadata';
13
15
  import { listBackupStorages } from '../services/backup-storage';
@@ -532,7 +534,9 @@ export async function getCompletions(words: string[], current: number): Promise<
532
534
 
533
535
  // Monitor targets - a module ID or one of celilo's own schedulable checks.
534
536
  // Sourced from SCHEDULABLE_BUILTIN_CHECKS rather than a hand-copied list, so
535
- // a new built-in check is completable the moment it is schedulable.
537
+ // a new built-in check is completable the moment it is schedulable. The two
538
+ // local-state checks (health coverage, hook jail) are deliberately not in
539
+ // that list — it is the audit categories — so they ride alongside by name.
536
540
  if (
537
541
  command === 'monitor' &&
538
542
  (args[1] === 'add' ||
@@ -548,7 +552,10 @@ export async function getCompletions(words: string[], current: number): Promise<
548
552
  .from(modules)
549
553
  .all()
550
554
  .map((m) => m.id);
551
- return filterSuggestions([...SCHEDULABLE_BUILTIN_CHECKS, ...moduleIds], args[2] || '');
555
+ return filterSuggestions(
556
+ [...SCHEDULABLE_BUILTIN_CHECKS, HEALTH_COVERAGE_CHECK, HOOK_JAIL_CHECK, ...moduleIds],
557
+ args[2] || '',
558
+ );
552
559
  }
553
560
 
554
561
  // Alerts subcommands
package/src/cli/index.ts CHANGED
@@ -619,7 +619,7 @@ Subcommands:
619
619
  show-config <id> Show all config including auto-derived values
620
620
  show-zone <id> Show module's network zone and config
621
621
 
622
- build <module-id> Execute build scripts (Nix + Ansible)
622
+ build <module-id> Execute module build scripts (Ansible)
623
623
 
624
624
  generate <id> Generate infrastructure code for module
625
625
  Options:
@@ -15,12 +15,12 @@
15
15
  * caddy-internal" for a further round after that. Prose did not hold this.
16
16
  */
17
17
  import { describe, expect, test } from 'bun:test';
18
+ import { readFileSync, readdirSync } from 'node:fs';
19
+ import { join } from 'node:path';
18
20
  import { parse } from 'yaml';
19
21
 
20
- const MANIFEST_PATH = new URL(
21
- '../../../../modules/celilo-web-console/manifest.yml',
22
- import.meta.url,
23
- ).pathname;
22
+ const MODULE_DIR = new URL('../../../../modules/celilo-web-console/', import.meta.url).pathname;
23
+ const MANIFEST_PATH = join(MODULE_DIR, 'manifest.yml');
24
24
 
25
25
  /**
26
26
  * Capabilities that give something a way in from the data plane.
@@ -73,3 +73,81 @@ describe('celilo-web-console stays in the control plane', () => {
73
73
  expect(manifest.provides?.capabilities ?? []).toEqual([]);
74
74
  });
75
75
  });
76
+
77
+ /**
78
+ * The build output the manifest's `build:` step writes, which is not source.
79
+ *
80
+ * Excluded from the walk below for two reasons, and the second is the one that
81
+ * matters. It holds two ~100MB compiled binaries, so reading it as text is
82
+ * absurd. And it holds a minified React bundle, which contains almost every
83
+ * short string you could think to search for — so scanning it reports ingress
84
+ * that nobody wrote. Both are gitignored, so on a fresh checkout the directory
85
+ * is absent entirely and the walk would silently be checking a different set of
86
+ * files than it checks on a machine that has run a build.
87
+ */
88
+ const BUILD_OUTPUT_DIR = 'files';
89
+
90
+ /**
91
+ * Every source file in the module's deployable body.
92
+ *
93
+ * A walk rather than a list, because the hole this guards is a file somebody
94
+ * ADDS. Listing today's files would pass over exactly the new Ansible task that
95
+ * opens the port.
96
+ */
97
+ function deployableBody(): { path: string; text: string }[] {
98
+ const found: { path: string; text: string }[] = [];
99
+ const walk = (dir: string) => {
100
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
101
+ if (entry.isDirectory()) {
102
+ if (entry.name !== BUILD_OUTPUT_DIR) walk(join(dir, entry.name));
103
+ continue;
104
+ }
105
+ const path = join(dir, entry.name);
106
+ found.push({ path, text: readFileSync(path, 'utf8') });
107
+ }
108
+ };
109
+ for (const sub of ['terraform', 'ansible']) walk(join(MODULE_DIR, sub));
110
+ return found;
111
+ }
112
+
113
+ /**
114
+ * How a deployable body would open a path in from the data plane.
115
+ *
116
+ * A reverse-proxy vhost, a DNAT or port-forward rule, a `natIp`, or a dedicated
117
+ * ingress address. None belongs on a `secure-mgmt` module, and every one is a
118
+ * plausible one-line addition by someone who wanted to reach the console
119
+ * without bringing up the VPN.
120
+ */
121
+ const INGRESS_TOKENS = [
122
+ 'caddy',
123
+ 'reverse_proxy',
124
+ 'natip',
125
+ 'exposeservice',
126
+ 'port_forward',
127
+ 'portforward',
128
+ 'dnat',
129
+ ];
130
+
131
+ describe("the console's deployable body opens no path in", () => {
132
+ test('the walk reaches the terraform and ansible it is checking', () => {
133
+ // Without this the assertion below is a confident statement about an empty
134
+ // set as soon as either directory is renamed — and the same is true on a
135
+ // fresh checkout, where the excluded build output does not exist at all.
136
+ const paths = deployableBody().map((file) => file.path);
137
+ expect(paths.some((path) => path.endsWith('terraform/main.tf.tpl'))).toBe(true);
138
+ expect(paths.some((path) => path.includes('/ansible/roles/'))).toBe(true);
139
+ expect(paths.some((path) => path.endsWith('.service.j2'))).toBe(true);
140
+ });
141
+
142
+ test('reads no build output, so a local build cannot change the verdict', () => {
143
+ const paths = deployableBody().map((file) => file.path);
144
+ expect(paths.filter((path) => path.includes(`/${BUILD_OUTPUT_DIR}/`))).toEqual([]);
145
+ });
146
+
147
+ test('names no reverse proxy, port forward or ingress address', () => {
148
+ const offenders = deployableBody()
149
+ .filter((file) => INGRESS_TOKENS.some((token) => file.text.toLowerCase().includes(token)))
150
+ .map((file) => file.path.slice(MODULE_DIR.length));
151
+ expect(offenders).toEqual([]);
152
+ });
153
+ });
package/src/db/schema.ts CHANGED
@@ -447,7 +447,6 @@ export const moduleBuilds = sqliteTable('module_builds', {
447
447
  version: text('version').notNull(),
448
448
  builtAt: integer('built_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
449
449
  artifacts: text('artifacts', { mode: 'json' }).$type<string[]>().notNull(), // Array of artifact paths
450
- environment: text('environment').$type<'nix' | 'system'>(),
451
450
  status: text('status').$type<BuildStatus>().notNull(),
452
451
  buildLog: text('build_log'), // Build output for debugging
453
452
  });
@@ -45,7 +45,7 @@ import { getOrCreateMasterKey } from '../secrets/master-key';
45
45
  import { buildControlPlaneApi } from '../services/api-principal-enrolment';
46
46
  import { recordCapabilityBinding, withBindingRecord } from '../services/capability-bindings';
47
47
  import { emitWebRoutesChangedAndWait } from '../services/celilo-events';
48
- import { getModuleSystems } from '../services/deployed-systems';
48
+ import { CONTROL_PLANE_MODULE_ID, getModuleSystems } from '../services/deployed-systems';
49
49
  import { withDnsInternalLedger } from '../services/dns-internal-records';
50
50
  import { withDnsRegistrationLedger } from '../services/dns-registrations';
51
51
  import { buildPortForwardStore } from '../services/port-forwards';
@@ -605,6 +605,20 @@ export async function loadCapabilityFunctions(
605
605
  caddyModuleId: provider.moduleId,
606
606
  dnsManagedDomains,
607
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
+ },
608
622
  // ISS-0035: register_route/unregister_routes emit this coarse signal
609
623
  // instead of SSHing caddy; the caddy provider's reconcile_routes
610
624
  // subscription re-renders the Caddyfile from web_routes. We await the
@@ -938,7 +952,6 @@ interface FirewallZones {
938
952
  }
939
953
 
940
954
  /** The module that IS celilo's control plane; its network is what we trust. */
941
- const CONTROL_PLANE_MODULE_ID = 'celilo-mgmt';
942
955
 
943
956
  function readZoneSubnet(db: DbClient, zone: string): string | undefined {
944
957
  const row = db
@@ -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,19 @@ export async function executeHookScript(
326
367
 
327
368
  const child = Bun.spawn({
328
369
  cmd: [...jail.cmd],
329
- env: hookChildEnv(broker.socketPath),
370
+ // `sandbox-exec` has no `--chdir`, so the plan names one and the spawn
371
+ // sets it. Without it the child inherits the operator's shell directory,
372
+ // which the profile does not name — and `bun` reads its cwd before it
373
+ // runs anything, so the hook dies with a message about nothing in
374
+ // particular. bubblewrap plans leave this absent and use `--chdir`.
375
+ ...(jail.cwd ? { cwd: jail.cwd } : {}),
376
+ env: hookChildEnv(
377
+ broker.socketPath,
378
+ remoteBroker.socketPath,
379
+ // The lint rides only on an unjailed run — its presence in the child's
380
+ // environment is the shim's signal to install it (task 4.7).
381
+ jail.mode === 'unjailed' ? mountSet : undefined,
382
+ ),
330
383
  stdout: 'pipe',
331
384
  stderr: 'pipe',
332
385
  });
@@ -346,8 +399,10 @@ export async function executeHookScript(
346
399
  killedFor = reason;
347
400
  // Refuse capability calls FIRST. A hook between `kill` and its own death
348
401
  // can still have a call in flight, and answering it is exactly the
349
- // failure this replaces.
402
+ // failure this replaces. The remote channel refuses for the same reason:
403
+ // a killed hook must not go on reaching systems (celilo#1003, D12).
350
404
  broker.stop();
405
+ remoteBroker?.stop();
351
406
  child.kill('SIGTERM');
352
407
  setTimeout(() => child.kill('SIGKILL'), SIGKILL_GRACE_MS).unref();
353
408
  };
@@ -398,10 +453,41 @@ export async function executeHookScript(
398
453
  if (!outcome.ok) throw deserializeError(outcome.error);
399
454
  return outcome.outputs;
400
455
  } finally {
456
+ // Remote first: its socket file lives inside the directory the capability
457
+ // broker's close removes.
458
+ remoteBroker?.close();
401
459
  broker.close();
402
460
  }
403
461
  }
404
462
 
463
+ /**
464
+ * The local directories this run's stream primitives may touch (design D12).
465
+ *
466
+ * Derived from the same inputs as the jail's mount set, which is what keeps
467
+ * the two views from drifting: everything writable here is writable there,
468
+ * and a path outside these roots is a path the jailed hook could not see
469
+ * anyway — the broker writing to it would produce a file the hook cannot
470
+ * read, reported as success.
471
+ *
472
+ * Planning function (Rule 10.4) — pure.
473
+ */
474
+ function remoteRoots(
475
+ context: HookContext,
476
+ jail: HookJailInputs | undefined,
477
+ ): { readable: string[]; writable: string[] } {
478
+ const writable: string[] = [context.stateDir, context.screenshotDir];
479
+ const readable: string[] = [];
480
+ if (jail) {
481
+ readable.push(jail.modulePath);
482
+ writable.push(join(jail.modulePath, 'generated'));
483
+ for (const input of jail.pathInputs) {
484
+ (input.access === 'write' ? writable : readable).push(input.value);
485
+ }
486
+ }
487
+ // Whatever a hook may write, it may also read back.
488
+ return { readable: [...readable, ...writable], writable };
489
+ }
490
+
405
491
  /**
406
492
  * The child's environment, built from an allow-list (design D5).
407
493
  *
@@ -422,7 +508,11 @@ export async function executeHookScript(
422
508
  *
423
509
  * Planning function (Rule 10.4) — pure.
424
510
  */
425
- export function hookChildEnv(socketPath: string): Record<string, string> {
511
+ export function hookChildEnv(
512
+ socketPath: string,
513
+ remoteSocketPath: string,
514
+ mountSet: MountSet | undefined,
515
+ ): Record<string, string> {
426
516
  const env: Record<string, string> = {};
427
517
 
428
518
  for (const name of FORWARDED_ENV) {
@@ -431,6 +521,10 @@ export function hookChildEnv(socketPath: string): Record<string, string> {
431
521
  }
432
522
 
433
523
  env[HOOK_SOCKET_ENV] = socketPath;
524
+ env[HOOK_REMOTE_SOCKET_ENV] = remoteSocketPath;
525
+ // The unjailed advisory lint's input (task 4.7). Absent when jailed — the
526
+ // shim installs the lint when and only when this variable is set.
527
+ if (mountSet) env[HOOK_MOUNT_SET_ENV] = mountSetEnvValue(mountSet);
434
528
  env[HOOK_PROTOCOL_VERSION_ENV] = String(HOOK_PROTOCOL_VERSION);
435
529
  // Forwarded when the operator set it, not synthesised from `debug` — a hook
436
530
  // reads the flag off `ctx.debug`, which crosses in the context frame.
@@ -465,10 +559,6 @@ function jailRequest(
465
559
  runnerPath: HOOK_RUNNER_PATH,
466
560
  runtimeModulePaths: runtimeModulePathsFor(HOOK_RUNNER_PATH),
467
561
  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
562
  };
473
563
  }
474
564
 
@@ -544,6 +634,14 @@ export interface InvokeHookOptions {
544
634
  * manifest hook's own `timeout`, being the more specific declaration.
545
635
  */
546
636
  timeoutMs?: number;
637
+ /**
638
+ * D12's target policy for the hook's remote operations, built by the caller
639
+ * via `remoteAccessPolicy(moduleId, db)` — the same caller-loads-it pattern
640
+ * as `systems`, and for the same reason: the executor stays decoupled from
641
+ * the database. Absent means every fleet-credential remote operation is
642
+ * refused (Rule 6.4).
643
+ */
644
+ remoteAccess?: RemoteAccessPolicy;
547
645
  }
548
646
 
549
647
  /**
@@ -846,6 +944,7 @@ export async function invokeHook(
846
944
  timeoutMs,
847
945
  idleTimeoutMs,
848
946
  jail: { modulePath, pathInputs: declaredPathInputs(signature, inputs) },
947
+ remoteAccess: options.remoteAccess,
849
948
  });
850
949
 
851
950
  // Validate outputs against the contract signature