@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
@@ -0,0 +1,395 @@
1
+ /**
2
+ * The unjailed advisory lint (hook-process-boundary task 4.7, design D8).
3
+ *
4
+ * On a host with no jail backend the hook runs with ambient filesystem
5
+ * access, so a hook that reads outside its tree works on that host and fails
6
+ * on the fleet — a divergence otherwise found in production one deploy at a
7
+ * time. This file closes the gap by making the runner shim check each
8
+ * filesystem access against the SAME mount set the jail would have enforced.
9
+ * It is the derivation's second consumer, which is the point: one computation
10
+ * and two consumers cannot drift the way a consumer and a hand-maintained
11
+ * list can.
12
+ *
13
+ * **This is a lint, not a security boundary, and neither the code nor its
14
+ * output may describe it as one.** It observes `node:fs` calls from inside
15
+ * the hook's own process, and module code bypasses it trivially — a `Bun.file`
16
+ * call, a `dlopen`, or a subprocess never crosses a wrapper. What it claims,
17
+ * and all it claims, is: on a jailed host, this access would have failed.
18
+ *
19
+ * **Channel.** The mount set arrives in the child's environment
20
+ * (`HOOK_MOUNT_SET_ENV`), set only when the run is unjailed — presence is the
21
+ * signal to install, so a jailed run carries nothing and installs nothing.
22
+ *
23
+ * **WHY THIS MODULE SELF-INSTALLS AT EVALUATION, AND WHY IT MUST STAY A
24
+ * LEAF.** Measured on Bun 1.3: the first ESM import of `node:fs` anywhere in
25
+ * a process resolves the builtin's named exports and never revisits them, so
26
+ * a wrapper planted on `require('node:fs')` AFTER that first import is
27
+ * invisible to every later `import { readFileSync } from 'node:fs'`. The
28
+ * shim's other imports (`jail.ts`, `@celilo/capabilities`) ESM-import
29
+ * `node:fs`, so the wrappers must be in place before any of them load. Hence
30
+ * `hook-runner-entry.ts`: the executor spawns IT, its first statement forces
31
+ * this module's evaluation and the install, and only then does the real
32
+ * runner load. Import order inside the runner is therefore irrelevant — but
33
+ * this module still imports nothing but `node:path`, `zod` (through
34
+ * `hook-protocol.ts`, which pulls no other builtins), and type-only imports,
35
+ * because the entry's guarantee is only as good as this file's graph is
36
+ * shallow. Importing `jail.ts` from here would silently disarm the lint; the
37
+ * integration tests in `unjailed-lint.test.ts` go red if that happens (they
38
+ * did, while this was being landed).
39
+ *
40
+ * The self-install is guarded on the runner shim's own environment variable,
41
+ * so a parent celilo process that imports this module for
42
+ * `mountSetEnvValue` never wraps its own filesystem.
43
+ */
44
+
45
+ import { basename, dirname, isAbsolute, resolve } from 'node:path';
46
+ import {
47
+ HOOK_MOUNT_SET_ENV,
48
+ HOOK_SOCKET_ENV,
49
+ type MountSetWire,
50
+ MountSetWireSchema,
51
+ } from './hook-protocol';
52
+ import type { MountSet } from './mount-set';
53
+
54
+ /**
55
+ * The two paths the jail's namespace itself provides — bubblewrap's
56
+ * `--proc /proc` and `--dev /dev` (`jail.ts`'s JAIL_NAMESPACE_ARGS is built
57
+ * from this constant, so the two cannot drift) — rather than derivation
58
+ * rows. Counting them absent would be a false warning on every hook that
59
+ * touched either.
60
+ */
61
+ export const JAIL_PROVIDED_PATHS = ['/proc', '/dev'] as const;
62
+
63
+ /**
64
+ * `realpathSync` captured at module load, before the wrappers go in.
65
+ * `classifyAccess` runs from INSIDE a wrapped call (a wrapper reports, report
66
+ * classifies), so reaching for the module property here would re-enter the
67
+ * wrapper and classify forever.
68
+ */
69
+ const PRISTINE_REALPATH = (require('node:fs') as typeof import('node:fs')).realpathSync;
70
+
71
+ /** The verdict for one access, against one mount set. */
72
+ export type AccessVerdict = 'allowed' | 'absent' | 'read-only';
73
+
74
+ /**
75
+ * Distinct paths warned about before the lint stops listing and summarises.
76
+ * A hook walking a directory outside the set would otherwise emit one line
77
+ * per file; after this many distinct paths the reader has the point.
78
+ */
79
+ const WARN_CAP = 25;
80
+
81
+ /**
82
+ * Serialise a derived mount set for `HOOK_MOUNT_SET_ENV`.
83
+ *
84
+ * The executor calls this with the set it just derived; `parseLintMountSet`
85
+ * is the only thing on the reading side.
86
+ */
87
+ export function mountSetEnvValue(set: MountSet): string {
88
+ return JSON.stringify(set);
89
+ }
90
+
91
+ /**
92
+ * Read and validate the mount set out of the child's environment.
93
+ *
94
+ * `undefined` when the variable is absent — the jailed case, and every
95
+ * invocation with no module tree. A value that fails validation is reported
96
+ * to stderr (the executor forwards it through the logger) and the run
97
+ * proceeds WITHOUT the lint rather than failing the hook over a diagnostic:
98
+ * the lint must never be the reason a deploy breaks. A silent skip would be
99
+ * the one thing worse than that, hence the stderr line.
100
+ */
101
+ export function parseLintMountSet(value: string | undefined): MountSetWire | undefined {
102
+ if (value === undefined || value === '') return undefined;
103
+ let json: unknown;
104
+ try {
105
+ json = JSON.parse(value);
106
+ } catch (error) {
107
+ process.stderr.write(
108
+ `hook runner: ${HOOK_MOUNT_SET_ENV} is not valid JSON (${error instanceof Error ? error.message : String(error)}); the unjailed advisory lint is off this run.\n`,
109
+ );
110
+ return undefined;
111
+ }
112
+ const parsed = MountSetWireSchema.safeParse(json);
113
+ if (parsed.success) return parsed.data;
114
+ process.stderr.write(
115
+ `hook runner: ${HOOK_MOUNT_SET_ENV} failed validation; the unjailed advisory lint is off this run.\n`,
116
+ );
117
+ return undefined;
118
+ }
119
+
120
+ /**
121
+ * Warnings emitted before the hook's logger exists are buffered here and
122
+ * flushed by `forwardLintWarnings`. In practice there are none — the shim
123
+ * does no filesystem work between load and hook start — but the lint must
124
+ * never assume that.
125
+ */
126
+ const buffered: string[] = [];
127
+ let emit: ((message: string) => void) | undefined;
128
+ let installed = false;
129
+
130
+ /**
131
+ * Install the wrappers now, if this process is an unjailed hook runner.
132
+ *
133
+ * Idempotent, and called from TWO places by design: this module's own
134
+ * evaluation, and `hook-runner-entry.ts`'s first statement. Which one lands
135
+ * first depends on Bun's import evaluation order, which is not a thing to
136
+ * reason about twice — the guard makes both orders correct, and the entry
137
+ * makes one of them certain before any other module in the child process can
138
+ * ESM-load `node:fs` (see the docblock at the top). No-op everywhere else: no
139
+ * runner socket in the environment means this is not a hook runner process.
140
+ */
141
+ export function installUnjailedLintIfUnjailed(): void {
142
+ if (installed) return;
143
+ if (process.env[HOOK_SOCKET_ENV] === undefined) return;
144
+ const set = parseLintMountSet(process.env[HOOK_MOUNT_SET_ENV]);
145
+ if (!set) return;
146
+ installed = true;
147
+ install(set, (message) => (emit ? emit(message) : buffered.push(message)));
148
+ }
149
+
150
+ /**
151
+ * Hand the lint's output to the hook's logger, draining anything buffered.
152
+ * Call once the shim's logger exists, before the hook script is imported.
153
+ */
154
+ export function forwardLintWarnings(to: (message: string) => void): void {
155
+ emit = to;
156
+ for (const message of buffered.splice(0)) to(message);
157
+ }
158
+
159
+ /**
160
+ * Classify one access against the mount set.
161
+ *
162
+ * Last matching row wins, in `entries` order — that is bubblewrap's own rule
163
+ * (`--ro-bind` then a later `--bind` overrides), and the derivation emits its
164
+ * rows in exactly that order. A path under no row is absent, which is what
165
+ * the jail actually produces (mount-set.ts: not "denied", ABSENT).
166
+ *
167
+ * Relative paths resolve against `set.chdir`, because that is the working
168
+ * directory the jailed hook runs in (`--chdir`, task 4.2i) — the child's real
169
+ * cwd is celilo's, which is a directory the jail does not contain.
170
+ */
171
+ export function classifyAccess(set: MountSetWire, path: string, write: boolean): AccessVerdict {
172
+ const absolute = isAbsolute(path) ? path : resolve(set.chdir, path);
173
+ const candidates = realpathCandidates(absolute);
174
+ let verdict: AccessVerdict = 'absent';
175
+ for (const candidate of candidates) {
176
+ verdict = strongest(verdict, classifyOne(set, candidate, write));
177
+ }
178
+ return verdict;
179
+ }
180
+
181
+ /**
182
+ * The lexical path plus, when it can be resolved, its realpath.
183
+ *
184
+ * The mount set the lint compares against is the REALPATHED one
185
+ * (`realpathRequest`, task 4.2k — the jail binds real paths). A dev checkout
186
+ * on macOS reaches the hook through symlinked prefixes (`/tmp` →
187
+ * `/private/tmp`, `/var` → `/private/var`), so a hook writing the path celilo
188
+ * handed it and the same path as bound can disagree lexically. The lint
189
+ * accepts either form as "inside": the lint runs only where there is no jail
190
+ * to match byte-for-byte, and the alternative is a warning on every state
191
+ * write on a Mac.
192
+ *
193
+ * The realpath of the FULL path usually does not exist — the check fires
194
+ * before a create, and `realpathSync` fails on the file being created. So
195
+ * this resolves the longest existing ancestor and reattaches whatever is
196
+ * left: for `<state>/cursor`, where only `<state>` exists, the second
197
+ * candidate is the realpath of `<state>` plus `/cursor`.
198
+ */
199
+ function realpathCandidates(path: string): string[] {
200
+ const candidates = [path];
201
+ let suffix = '';
202
+ let current = path;
203
+ for (;;) {
204
+ try {
205
+ const real = PRISTINE_REALPATH(current) + suffix;
206
+ if (!candidates.includes(real)) candidates.push(real);
207
+ return candidates;
208
+ } catch {
209
+ const parent = dirname(current);
210
+ if (parent === current) return candidates;
211
+ suffix = `/${basename(current)}${suffix}`;
212
+ current = parent;
213
+ }
214
+ }
215
+ }
216
+
217
+ function classifyOne(set: MountSetWire, absolute: string, write: boolean): AccessVerdict {
218
+ let verdict: AccessVerdict = 'absent';
219
+ for (const entry of set.entries) {
220
+ if (!covers(entry.path, absolute)) continue;
221
+ verdict = entry.mode === 'ro' && write ? 'read-only' : 'allowed';
222
+ }
223
+ for (const provided of JAIL_PROVIDED_PATHS) {
224
+ if (covers(provided, absolute)) verdict = 'allowed';
225
+ }
226
+ return verdict;
227
+ }
228
+
229
+ function covers(root: string, path: string): boolean {
230
+ const prefix = root.endsWith('/') ? root : `${root}/`;
231
+ return path === root || path.startsWith(prefix);
232
+ }
233
+
234
+ /** The more informative of two verdicts, for the realpath candidate pair. */
235
+ function strongest(a: AccessVerdict, b: AccessVerdict): AccessVerdict {
236
+ if (a === 'allowed' || b === 'allowed') return 'allowed';
237
+ if (a === 'read-only' || b === 'read-only') return 'read-only';
238
+ return 'absent';
239
+ }
240
+
241
+ /**
242
+ * Wrap every path-taking function this file knows about on both `node:fs`
243
+ * and `node:fs/promises`. The wrap fires BEFORE the underlying call, so it
244
+ * observes attempts — including attempts that succeed locally only because
245
+ * there is no jail, which is exactly the divergence it exists to surface.
246
+ *
247
+ * Warnings dedupe per (verdict, path) and are capped at WARN_CAP distinct
248
+ * paths, with one notice when the cap bites.
249
+ */
250
+ function install(set: MountSetWire, warn: (message: string) => void): void {
251
+ const warned = new Set<string>();
252
+ let suppressed = 0;
253
+
254
+ const report = (rawPath: string, write: boolean): void => {
255
+ if (typeof rawPath !== 'string' || rawPath === '') return;
256
+ const absolute = isAbsolute(rawPath) ? rawPath : resolve(set.chdir, rawPath);
257
+ const verdict = classifyAccess(set, absolute, write);
258
+ if (verdict === 'allowed') return;
259
+ const key = `${verdict}:${absolute}`;
260
+ if (warned.has(key)) return;
261
+ if (warned.size >= WARN_CAP) {
262
+ suppressed += 1;
263
+ if (suppressed === 1) {
264
+ warn(
265
+ `Hook advisory: further path(s) outside the hook's mount set will be suppressed after ${WARN_CAP}.`,
266
+ );
267
+ }
268
+ return;
269
+ }
270
+ warned.add(key);
271
+ warn(advisoryMessage(verdict, absolute));
272
+ };
273
+
274
+ for (const moduleId of ['node:fs', 'node:fs/promises'] as const) {
275
+ const mod = require(moduleId) as Record<string, unknown>;
276
+ for (const [name, pathArgsFor] of Object.entries(PATH_ARGS)) {
277
+ const original = mod[name];
278
+ if (typeof original !== 'function') continue;
279
+ mod[name] = function (this: unknown, ...call: unknown[]) {
280
+ const pathArgs = typeof pathArgsFor === 'function' ? pathArgsFor(call) : pathArgsFor;
281
+ for (const [index, write] of pathArgs) {
282
+ const value = call[index];
283
+ if (typeof value === 'string') report(value, write);
284
+ else if (value instanceof URL && value.protocol === 'file:') {
285
+ report(value.pathname, write);
286
+ }
287
+ }
288
+ return (original as (...args: unknown[]) => unknown).apply(this, call);
289
+ };
290
+ }
291
+ }
292
+ }
293
+
294
+ function advisoryMessage(verdict: AccessVerdict, path: string): string {
295
+ if (verdict === 'read-only') {
296
+ return [
297
+ `Hook advisory: '${path}' is read-only in the hook's mount set,`,
298
+ 'so on a jailed host this write would fail.',
299
+ 'Advisory lint, not a security boundary; module code bypasses it trivially.',
300
+ ].join(' ');
301
+ }
302
+ return [
303
+ `Hook advisory: '${path}' is outside the hook's mount set,`,
304
+ 'so on a jailed host this access would fail (the path is absent there, ENOENT).',
305
+ 'Advisory lint, not a security boundary; module code bypasses it trivially.',
306
+ ].join(' ');
307
+ }
308
+
309
+ /**
310
+ * Which arguments of which functions carry a path, and whether the access is
311
+ * a write. Read and write variants share the table; a name missing from a
312
+ * given module is simply skipped, so one table covers `node:fs`, its `Sync`
313
+ * variants and `node:fs/promises`.
314
+ *
315
+ * Deliberately a table of the common calls rather than an exhaustive census:
316
+ * a function missed here is a warning not emitted, never a behaviour change.
317
+ * The lint is advisory and module code bypasses it trivially (design D8);
318
+ * exhaustive coverage would buy a boundary-shaped guarantee it cannot keep.
319
+ */
320
+ const READ0: readonly (readonly [number, boolean])[] = [[0, false]];
321
+ const WRITE0: readonly (readonly [number, boolean])[] = [[0, true]];
322
+ const TWO_PATH: readonly (readonly [number, boolean])[] = [
323
+ [0, false],
324
+ [1, true],
325
+ ];
326
+
327
+ const PATH_ARGS: Record<
328
+ string,
329
+ | readonly (readonly [number, boolean])[]
330
+ | ((call: unknown[]) => readonly (readonly [number, boolean])[])
331
+ > = {
332
+ // Reads.
333
+ readFile: READ0,
334
+ readFileSync: READ0,
335
+ readdir: READ0,
336
+ readdirSync: READ0,
337
+ stat: READ0,
338
+ statSync: READ0,
339
+ lstat: READ0,
340
+ lstatSync: READ0,
341
+ access: READ0,
342
+ accessSync: READ0,
343
+ existsSync: READ0,
344
+ realpath: READ0,
345
+ realpathSync: READ0,
346
+ readlink: READ0,
347
+ readlinkSync: READ0,
348
+ createReadStream: READ0,
349
+ opendir: READ0,
350
+ opendirSync: READ0,
351
+ // Writes.
352
+ writeFile: WRITE0,
353
+ writeFileSync: WRITE0,
354
+ appendFile: WRITE0,
355
+ appendFileSync: WRITE0,
356
+ mkdir: WRITE0,
357
+ mkdirSync: WRITE0,
358
+ mkdtemp: WRITE0,
359
+ mkdtempSync: WRITE0,
360
+ rm: WRITE0,
361
+ rmSync: WRITE0,
362
+ rmdir: WRITE0,
363
+ rmdirSync: WRITE0,
364
+ unlink: WRITE0,
365
+ unlinkSync: WRITE0,
366
+ chmod: WRITE0,
367
+ chmodSync: WRITE0,
368
+ chown: WRITE0,
369
+ chownSync: WRITE0,
370
+ truncate: WRITE0,
371
+ truncateSync: WRITE0,
372
+ utimes: WRITE0,
373
+ utimesSync: WRITE0,
374
+ createWriteStream: WRITE0,
375
+ symlink: [[1, true]],
376
+ // Both ends are paths.
377
+ rename: TWO_PATH,
378
+ renameSync: TWO_PATH,
379
+ copyFile: TWO_PATH,
380
+ copyFileSync: TWO_PATH,
381
+ cp: TWO_PATH,
382
+ cpSync: TWO_PATH,
383
+ // Flag-dependent: 'r' reads, everything else writes.
384
+ open: OPEN_FLAGS,
385
+ openSync: OPEN_FLAGS,
386
+ };
387
+
388
+ /** `open`'s flags argument decides read from write. */
389
+ function OPEN_FLAGS(call: unknown[]): readonly (readonly [number, boolean])[] {
390
+ const flags = call[1];
391
+ const flag = typeof flags === 'string' ? flags : 'r';
392
+ return [[0, flag !== 'r']];
393
+ }
394
+
395
+ installUnjailedLintIfUnjailed();
@@ -309,6 +309,12 @@ export const CAPABILITY_NAME_BASELINE: readonly CapabilityNameRow[] = [
309
309
  count: 2,
310
310
  why: "S16 — two more copies of S15's decision; fixing S15 removes all three (#938)",
311
311
  },
312
+ {
313
+ file: 'apps/celilo/src/services/module-deploy.ts',
314
+ capability: 'public_web',
315
+ count: 1,
316
+ why: "D10 (capability-owned-tables stage 4): the public_web PROVIDER's own deploy writes the static-content release set into its generated inventory, so a rebuilt host recovers with no consumer involvement. The branch checks the deploying module's manifest `provides`, not a module name; the capability-loader callback is the other half of the same seam.",
317
+ },
312
318
  {
313
319
  file: 'apps/celilo/src/services/zone-policy.ts',
314
320
  capability: 'public_web',
@@ -488,7 +494,13 @@ export const PROVIDER_LITERAL_BASELINE: readonly ProviderLiteralRow[] = [
488
494
  {
489
495
  file: 'packages/capabilities/src/public-web.ts',
490
496
  literal: '/srv/www',
491
- count: 4,
497
+ count: 3,
492
498
  why: "X8 — the Caddyfile generator knows caddy's on-disk asset layout (#940)",
493
499
  },
500
+ {
501
+ file: 'apps/celilo/src/services/static-content-converge.ts',
502
+ literal: '/srv/www',
503
+ count: 1,
504
+ why: "D10 (capability-owned-tables stage 4): the converge's slug-collision error names the on-disk release path an operator must fix. The role under modules/caddy owns the real path handling; this is message text, not path logic.",
505
+ },
494
506
  ];
@@ -142,7 +142,10 @@ export function scanModuleScriptSource(file: string, source: string): ScanViolat
142
142
  * `@celilo/capabilities`, whose `remote.ts` builds the `ssh … root@` string
143
143
  * that every one of these rules exists to keep OUT of module code. Scanning it
144
144
  * would fail every module in the fleet on the implementation of the primitives
145
- * they were told to use.
145
+ * they were told to use. The package itself is NOT exempt —
146
+ * `scanCapabilityPackageSource` scans it everywhere except those primitive
147
+ * files (celilo#1014), which is how `public-web.ts`'s hand-built ssh came to
148
+ * light.
146
149
  */
147
150
  export function moduleScriptFiles(scriptsDir: string): string[] {
148
151
  if (!existsSync(scriptsDir) || !statSync(scriptsDir).isDirectory()) return [];
@@ -173,3 +176,59 @@ export function scanModuleDirectory(moduleDir: string): ScanViolation[] {
173
176
  export function formatViolations(violations: ScanViolation[]): string {
174
177
  return violations.map((v) => ` ${v.file}:${v.line}\n → ${v.rule}. ${v.hint}`).join('\n');
175
178
  }
179
+
180
+ /**
181
+ * The files inside `@celilo/capabilities` that implement the remote-exec
182
+ * primitives themselves. `remote.ts` is the ONE sanctioned place the
183
+ * `ssh … root@` string is built (its own header says so), so the narrowed
184
+ * scan exempts these files and nothing else.
185
+ *
186
+ * celilo#1014: the old exemption was the whole package — `moduleScriptFiles`
187
+ * skipped `node_modules` entirely, on the reasoning that scanning the bundle
188
+ * would fail every module on `remote.ts`. The whole-package shape also
189
+ * exempted `public-web.ts`, which was hand-building the same ssh string, and
190
+ * no gate could see it. Naming the primitive files instead of skipping the
191
+ * package is the fix for that class.
192
+ */
193
+ export const REMOTE_PRIMITIVE_FILES: readonly string[] = ['remote.ts'];
194
+
195
+ /**
196
+ * The narrowed file set: every production `.ts` in the package source EXCEPT
197
+ * the primitive-implementation files. Returned separately from the violations
198
+ * so a gate can prove its own reach — an empty violation list must never be
199
+ * indistinguishable from "scanned nothing".
200
+ */
201
+ export function capabilityPackageSourceFiles(srcDir: string): string[] {
202
+ if (!existsSync(srcDir) || !statSync(srcDir).isDirectory()) return [];
203
+ return readdirSync(srcDir)
204
+ .filter(
205
+ (entry) =>
206
+ entry.endsWith('.ts') &&
207
+ !entry.endsWith('.test.ts') &&
208
+ !REMOTE_PRIMITIVE_FILES.includes(entry),
209
+ )
210
+ .sort();
211
+ }
212
+
213
+ /**
214
+ * Scan the `@celilo/capabilities` source tree under the exemption's narrowed
215
+ * shape: every production `.ts` EXCEPT the files implementing the primitive.
216
+ *
217
+ * Takes the directory rather than finding it, so the same function serves both
218
+ * copies of the package the fleet actually runs: the workspace source
219
+ * (`packages/capabilities/src`, where the code is authored) and, once the next
220
+ * version publishes, each module's bundled copy
221
+ * (`modules/<m>/scripts/node_modules/@celilo/capabilities/src`). The bundled
222
+ * copies on a checkout made before that publish still carry the pre-fix code,
223
+ * so the in-repo gate reads the workspace source until then — scanning a
224
+ * snapshot no commit in this repo can fix would leave the gate red in exactly
225
+ * the PR that repairs the defect.
226
+ */
227
+ export function scanCapabilityPackageSource(srcDir: string): ScanViolation[] {
228
+ return capabilityPackageSourceFiles(srcDir).flatMap((entry) =>
229
+ scanModuleScriptSource(
230
+ join('@celilo/capabilities/src', entry),
231
+ readFileSync(join(srcDir, entry), 'utf-8'),
232
+ ),
233
+ );
234
+ }
@@ -14,7 +14,14 @@
14
14
  import { describe, expect, test } from 'bun:test';
15
15
  import { existsSync, readdirSync, statSync } from 'node:fs';
16
16
  import { join, resolve } from 'node:path';
17
- import { formatViolations, moduleScriptFiles, scanModuleDirectory } from './module-script-scan';
17
+ import {
18
+ REMOTE_PRIMITIVE_FILES,
19
+ capabilityPackageSourceFiles,
20
+ formatViolations,
21
+ moduleScriptFiles,
22
+ scanCapabilityPackageSource,
23
+ scanModuleDirectory,
24
+ } from './module-script-scan';
18
25
 
19
26
  /** Walk up from this test to the repo root (the dir holding both modules/ and apps/). */
20
27
  function repoRoot(): string {
@@ -48,3 +55,34 @@ describe('recurrence gate: modules never hand-build SSH', () => {
48
55
  );
49
56
  });
50
57
  });
58
+
59
+ describe('recurrence gate: @celilo/capabilities itself never hand-builds SSH', () => {
60
+ // The workspace source — where the package is authored, and the only copy a
61
+ // commit in this repo can fix. Each module's bundled copy is an npm snapshot
62
+ // that refreshes on the next publish, so the gate reads the source of truth.
63
+ const capabilitiesSrc = join(repoRoot(), 'packages', 'capabilities', 'src');
64
+
65
+ test('scans the package source except the remote-primitive file, and the exclusion engaged', () => {
66
+ // Reach probe, not reasoning: the primitive file must exist and be absent
67
+ // from the scanned set, or the narrowing below is proving nothing.
68
+ const primitivePresent = REMOTE_PRIMITIVE_FILES.every((f) =>
69
+ existsSync(join(capabilitiesSrc, f)),
70
+ );
71
+ expect(primitivePresent).toBe(true);
72
+
73
+ const scannedFiles = capabilityPackageSourceFiles(capabilitiesSrc);
74
+ for (const primitive of REMOTE_PRIMITIVE_FILES) {
75
+ expect(scannedFiles).not.toContain(primitive);
76
+ }
77
+ expect(scannedFiles.length).toBeGreaterThan(10);
78
+ expect(scannedFiles).toContain('public-web.ts');
79
+ });
80
+
81
+ test('no file outside the remote-primitive seam hand-builds SSH', () => {
82
+ const violations = scanCapabilityPackageSource(capabilitiesSrc);
83
+ expect(
84
+ violations,
85
+ `Capability package policy violations:\n${formatViolations(violations)}`,
86
+ ).toEqual([]);
87
+ });
88
+ });
@@ -299,7 +299,7 @@ describe('recurrence gate: celilo core holds no module business — Scan A (tabl
299
299
  measured,
300
300
  baseline,
301
301
  (table, capability) => `table '${table}' is owned by capability '${capability}'`,
302
- "A new table modelling one capability's domain belongs in that provider's module_configs\n (the wireguard-manager D2 precedent), not in core's schema. If core genuinely must\n hold it, add it to CAPABILITY_OWNED_TABLES with the reason.",
302
+ "A new table modelling one capability's domain is DECLARED by that capability, beside its\n interface in packages/capabilities/src/<name>.ts, and registered in\n CAPABILITY_DECLARED_TABLES (declared-tables.ts). The CREATE TABLE still lives in\n core's drizzle migrations: a declaration is metadata and a version anchor, not a\n second way to ship schema. It does NOT go in the provider's module_configs. That\n destination was superseded on 2026-08-22 and ruled out by peba on 2026-09-01\n (openspec/changes/capability-owned-tables, ruling 5). If core genuinely must hold\n the table, add it to CAPABILITY_OWNED_TABLES with the reason.",
303
303
  );
304
304
  expect(
305
305
  problems,
@@ -0,0 +1,66 @@
1
+ /**
2
+ * D8's three states, asserted one branch at a time: only "used to jail and has
3
+ * stopped" raises. Steady-state unjailed — a Mac that never jailed — is a
4
+ * configuration fact the doctor reports, never an alert.
5
+ */
6
+
7
+ import { describe, expect, test } from 'bun:test';
8
+ import type { JailModeRecord } from '../../hooks/jail';
9
+ import { hookJailFailingKeys } from './hook-jail';
10
+
11
+ const HOST = 'celilo-mgr';
12
+
13
+ const regressed: JailModeRecord = {
14
+ mode: 'unjailed',
15
+ backend: 'none',
16
+ reason: 'bubblewrap is installed but could not build a namespace, so hooks run unjailed.',
17
+ host: HOST,
18
+ recordedAt: '2026-08-28T09:00:00.000Z',
19
+ lastJailed: { backend: 'bubblewrap', recordedAt: '2026-08-27T09:00:00.000Z' },
20
+ };
21
+
22
+ describe('hookJailFailingKeys', () => {
23
+ test('a host that used to jail and has stopped raises, naming the host and the reason', () => {
24
+ const failing = hookJailFailingKeys({ record: regressed, host: HOST }, 'critical');
25
+ expect(failing).toHaveLength(1);
26
+ expect(failing[0]?.key).toBe(`builtin:hook_jail/host:${HOST}`);
27
+ expect(failing[0]?.severity).toBe('critical');
28
+ expect(failing[0]?.message).toContain(HOST);
29
+ expect(failing[0]?.message).toContain('until 2026-08-27T09:00:00.000Z');
30
+ expect(failing[0]?.message).toContain('could not build a namespace');
31
+ });
32
+
33
+ test('steady-state unjailed is not an event', () => {
34
+ const neverJailed: JailModeRecord = {
35
+ mode: 'unjailed',
36
+ backend: 'none',
37
+ reason: 'macOS has no hook jail yet',
38
+ host: HOST,
39
+ recordedAt: '2026-08-28T09:00:00.000Z',
40
+ };
41
+ expect(hookJailFailingKeys({ record: neverJailed, host: HOST }, 'critical')).toEqual([]);
42
+ });
43
+
44
+ test('a jailed host raises nothing, which is also how the alert resolves', () => {
45
+ const healthy: JailModeRecord = {
46
+ mode: 'jailed',
47
+ backend: 'bubblewrap',
48
+ host: HOST,
49
+ recordedAt: '2026-08-28T09:00:00.000Z',
50
+ };
51
+ expect(hookJailFailingKeys({ record: healthy, host: HOST }, 'critical')).toEqual([]);
52
+ });
53
+
54
+ test('no record yet raises nothing', () => {
55
+ expect(hookJailFailingKeys({ record: undefined, host: HOST }, 'critical')).toEqual([]);
56
+ });
57
+
58
+ test("a record written by another host is a move, not this host's regression", () => {
59
+ expect(hookJailFailingKeys({ record: regressed, host: 'a-new-box' }, 'critical')).toEqual([]);
60
+ });
61
+
62
+ test("the monitor's severity is the alert's severity", () => {
63
+ const failing = hookJailFailingKeys({ record: regressed, host: HOST }, 'warning');
64
+ expect(failing[0]?.severity).toBe('warning');
65
+ });
66
+ });