@celilo/cli 1.14.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 (57) hide show
  1. package/CELILO_CORE_MODULES.md +1 -1
  2. package/CELILO_SUBSYSTEMS.md +25 -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 +14 -0
  17. package/src/hooks/executor.ts +110 -17
  18. package/src/hooks/hook-jail-toolchain-reach.test.ts +224 -0
  19. package/src/hooks/hook-jail-unreachability.test.ts +28 -2
  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 +9 -3
  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 +128 -11
  27. package/src/hooks/mount-set.test.ts +28 -6
  28. package/src/hooks/mount-set.ts +34 -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 +251 -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/hook-jail.test.ts +66 -0
  42. package/src/services/alerting/hook-jail.ts +70 -0
  43. package/src/services/alerting/run-monitor.test.ts +62 -0
  44. package/src/services/alerting/run-monitor.ts +12 -0
  45. package/src/services/alerting/sweep-runner.test.ts +1 -0
  46. package/src/services/backup-create.ts +3 -0
  47. package/src/services/backup-restore.ts +2 -0
  48. package/src/services/deploy-ansible.ts +9 -1
  49. package/src/services/health-runner.ts +2 -0
  50. package/src/services/module-build.test.ts +1 -64
  51. package/src/services/module-build.ts +10 -86
  52. package/src/services/module-deploy.ts +20 -0
  53. package/src/services/remote-access.test.ts +139 -0
  54. package/src/services/remote-access.ts +98 -0
  55. package/src/services/restore-from-file.ts +6 -1
  56. package/src/services/static-content-converge.test.ts +338 -0
  57. 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
  });
@@ -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
@@ -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