@celilo/cli 1.8.0 → 1.9.1

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 (66) hide show
  1. package/CELILO_CORE_MODULES.md +2 -0
  2. package/CELILO_SUBSYSTEMS.md +2 -0
  3. package/drizzle/0028_capability_bindings.sql +26 -0
  4. package/drizzle/0029_module_instances.sql +58 -0
  5. package/drizzle/meta/_journal.json +14 -0
  6. package/package.json +2 -2
  7. package/src/cli/commands/module-show.ts +1 -0
  8. package/src/db/foreign-keys.test.ts +101 -0
  9. package/src/db/schema.ts +161 -5
  10. package/src/hooks/broker.test.ts +153 -0
  11. package/src/hooks/broker.ts +307 -0
  12. package/src/hooks/capability-loader-bindings.test.ts +163 -0
  13. package/src/hooks/capability-loader-firewall.test.ts +108 -0
  14. package/src/hooks/capability-loader.test.ts +10 -2
  15. package/src/hooks/capability-loader.ts +59 -2
  16. package/src/hooks/define-hook.test.ts +1 -0
  17. package/src/hooks/executor.test.ts +7 -0
  18. package/src/hooks/executor.ts +245 -111
  19. package/src/hooks/hook-protocol.test.ts +192 -0
  20. package/src/hooks/hook-protocol.ts +275 -0
  21. package/src/hooks/hook-runner.ts +231 -0
  22. package/src/hooks/hook-state-dir.test.ts +109 -0
  23. package/src/hooks/hook-timeout.test.ts +104 -0
  24. package/src/hooks/hook-trespass.test.ts +202 -0
  25. package/src/hooks/injected-capabilities.test.ts +75 -0
  26. package/src/hooks/mount-set.test.ts +148 -0
  27. package/src/hooks/mount-set.ts +234 -0
  28. package/src/hooks/test-fixtures/capability-calling-hook.ts +79 -0
  29. package/src/hooks/test-fixtures/runaway-hook.ts +26 -0
  30. package/src/hooks/test-fixtures/sigterm-ignoring-hook.ts +22 -0
  31. package/src/manifest/contracts/v1.ts +21 -6
  32. package/src/manifest/validate-provider-views.test.ts +61 -0
  33. package/src/manifest/validate.ts +21 -14
  34. package/src/module/packaging/module-state-directory.test.ts +105 -0
  35. package/src/module/packaging/package-rules.ts +10 -2
  36. package/src/policy/capability-shape-baseline.ts +8 -0
  37. package/src/policy/capability-shape.ts +13 -1
  38. package/src/policy/module-business-baseline.ts +36 -0
  39. package/src/policy/module-dep-reachability.test.ts +167 -0
  40. package/src/services/alerting/ack.test.ts +2 -2
  41. package/src/services/alerting/deferral.test.ts +2 -2
  42. package/src/services/alerting/delivery-loop.test.ts +2 -2
  43. package/src/services/alerting/deploy-hooks.test.ts +2 -2
  44. package/src/services/alerting/inbound-poller.test.ts +2 -2
  45. package/src/services/alerting/inbound.test.ts +2 -2
  46. package/src/services/alerting/notification-responder.test.ts +2 -2
  47. package/src/services/alerting/run-monitor.test.ts +2 -2
  48. package/src/services/alerting/store.test.ts +2 -2
  49. package/src/services/alerting/sweep-runner.test.ts +2 -2
  50. package/src/services/alerting/tokens.test.ts +2 -2
  51. package/src/services/capability-bindings.test.ts +104 -0
  52. package/src/services/capability-bindings.ts +107 -0
  53. package/src/services/capability-table-rows.test.ts +2 -2
  54. package/src/services/consumer-cleanup.test.ts +40 -3
  55. package/src/services/dns-internal-records.test.ts +3 -3
  56. package/src/services/fleet-checks.test.ts +4 -4
  57. package/src/services/module-instances.test.ts +198 -0
  58. package/src/services/module-instances.ts +96 -0
  59. package/src/services/module-journal.test.ts +2 -2
  60. package/src/services/module-subscriptions.test.ts +1 -1
  61. package/src/services/port-forwards.test.ts +2 -2
  62. package/src/services/trusted-sources.test.ts +3 -3
  63. package/src/templates/ingress-ip.test.ts +31 -0
  64. package/src/test-utils/database.ts +31 -1
  65. package/src/test-utils/module-fixtures.ts +147 -35
  66. package/src/test-utils/setup-test-db.ts +0 -80
@@ -0,0 +1,234 @@
1
+ /**
2
+ * The hook jail's mount set (openspec/changes/hook-process-boundary, D9).
3
+ *
4
+ * A jailed hook sees exactly the paths listed here and nothing else. Not
5
+ * "denied" — ABSENT. A hook reaching for celilo's master key gets `ENOENT`,
6
+ * because inside the jail there is no such file. That is the acceptance
7
+ * criterion, and it is satisfied by absence rather than by a check.
8
+ *
9
+ * The set is DERIVED, never declared by the module. A module cannot ask for
10
+ * more, which is the whole point.
11
+ *
12
+ * This file is pure. It computes a description of a filesystem view and
13
+ * touches nothing. That is what lets `bwrap` is never in the mount set be a
14
+ * hermetic test rather than an integration one, and what lets the unjailed
15
+ * advisory lint (task 4.7) consume the same computation instead of a second
16
+ * one that can drift from it.
17
+ */
18
+
19
+ import { dirname, isAbsolute, join, resolve } from 'node:path';
20
+ import type { PathAccess } from '@celilo/capabilities';
21
+
22
+ /**
23
+ * `tmpfs` is not an access level, it is "put a fresh empty filesystem here".
24
+ * It has to be its own mode because ORDER matters and a tmpfs erases whatever
25
+ * the jail would otherwise see at that path.
26
+ */
27
+ export type MountMode = 'ro' | 'rw' | 'tmpfs';
28
+
29
+ export interface MountEntry {
30
+ /**
31
+ * The path, IDENTICAL inside and outside the jail. Never remapped.
32
+ *
33
+ * Paths cross the capability boundary as strings: a hook hands the broker a
34
+ * path it wrote to, and the broker has to be able to read it. A remapped
35
+ * mount would make those two names disagree and the failure would look like
36
+ * a missing file rather than a translation bug.
37
+ */
38
+ readonly path: string;
39
+ readonly mode: MountMode;
40
+ /** Why this row exists. Surfaced by the unjailed lint and by `system doctor`. */
41
+ readonly reason: string;
42
+ }
43
+
44
+ export interface MountSet {
45
+ /**
46
+ * ORDER IS SEMANTIC. bubblewrap applies these in sequence and a later entry
47
+ * wins over an earlier one, which is what lets a read-write directory sit
48
+ * inside a read-only tree. Do not sort this list.
49
+ */
50
+ readonly entries: readonly MountEntry[];
51
+ /**
52
+ * The jail's working directory.
53
+ *
54
+ * The spawn does not set `cwd`, so a hook child inherits celilo's — whatever
55
+ * directory the operator's shell happened to be in. Inside the jail that
56
+ * directory usually does not exist, and bubblewrap fails on a path nobody
57
+ * chose. So the jail names one explicitly.
58
+ */
59
+ readonly chdir: string;
60
+ }
61
+
62
+ /** A path input the contract declared, paired with the value the framework resolved. */
63
+ export interface DeclaredPathInput {
64
+ readonly name: string;
65
+ readonly value: string;
66
+ readonly access: PathAccess;
67
+ }
68
+
69
+ export interface MountSetRequest {
70
+ /**
71
+ * The module's own tree. Comes from the DB as `module.sourcePath`, which is
72
+ * NOT guaranteed to sit under the module store — a restored database carries
73
+ * the absolute paths of the box it was taken from (ISS-0052). Resolved below
74
+ * before it becomes a bind-mount argument.
75
+ */
76
+ readonly modulePath: string;
77
+ /** `<module>/state`, celilo#1000's sanctioned writable directory. */
78
+ readonly stateDir: string;
79
+ /** `<module>/screenshots/<run>`, this run only. Absent when the hook takes none. */
80
+ readonly screenshotDir?: string;
81
+ /** The directory holding the broker's unix socket. */
82
+ readonly socketDir: string;
83
+ /** The interpreter celilo spawns (`process.execPath`). */
84
+ readonly runtimePath: string;
85
+ /** The runner shim, which lives in celilo's tree rather than the module's. */
86
+ readonly runnerPath: string;
87
+ /** Contract-declared path inputs, already resolved to values. */
88
+ readonly pathInputs: readonly DeclaredPathInput[];
89
+ /**
90
+ * The operator's `~/.ssh`, read-only, STAGE 2 ONLY.
91
+ *
92
+ * `remote.ts` still runs inside the hook and needs the key. Stage 3 brokers
93
+ * those calls and drops this row, which is what turns D12's target check
94
+ * from a convention into a boundary. Dropping it before stage 3 lands
95
+ * hardens nothing — it just stops every hook reaching its own systems.
96
+ */
97
+ readonly sshDir?: string;
98
+ }
99
+
100
+ /** Directories whose contents the runtime needs in order to start at all. */
101
+ const RUNTIME_SUPPORT_DIRS = ['/usr/lib', '/lib', '/lib64', '/etc/ssl'] as const;
102
+
103
+ /**
104
+ * Paths that must NEVER appear in a mount set, whatever asks for them.
105
+ *
106
+ * `bwrap` is the load-bearing entry and the reason this list exists rather
107
+ * than being a comment. celilo ships an AppArmor profile granting `userns` to
108
+ * `/usr/bin/bwrap` so the jail can be built at all, and that grant applies to
109
+ * anyone on the box who runs it. A jailed hook that could exec `bwrap` would
110
+ * get a namespace of its own, be uid 0 inside it with `CAP_SYS_ADMIN`, and
111
+ * reach kernel surface an unprivileged user cannot otherwise touch — which is
112
+ * the entire reason Ubuntu restricts unprivileged user namespaces.
113
+ *
114
+ * `bwrap` runs OUTSIDE the jail because it is what creates the jail. It does
115
+ * not need to exist inside one, so leaving it out costs nothing.
116
+ *
117
+ * Task 4.10 already predicts how this gets undone: a browser hook fails with
118
+ * `bwrap: command not found`, a developer reads a missing binary and adds it,
119
+ * the suite goes green, and the escape path is open with nothing to read. The
120
+ * test on this constant is that missing thing to read.
121
+ */
122
+ const NEVER_MOUNT = ['/usr/bin/bwrap', '/usr/local/bin/bwrap', '/bin/bwrap'] as const;
123
+
124
+ function entry(path: string, mode: MountMode, reason: string): MountEntry {
125
+ return { path, mode, reason };
126
+ }
127
+
128
+ /**
129
+ * Compute the filesystem view a hook gets.
130
+ *
131
+ * Pure: resolves paths lexically and reads nothing from disk. `resolve` is not
132
+ * `realpath` — it cannot follow a symlink, because following one is I/O. The
133
+ * caller supplies already-real paths; on macOS that distinction is the
134
+ * difference between a rule that applies and one that silently does not
135
+ * (task 4.8), so the caller's `realpath` is not optional.
136
+ */
137
+ export function deriveMountSet(request: MountSetRequest): MountSet {
138
+ const modulePath = resolve(request.modulePath);
139
+ const entries: MountEntry[] = [];
140
+
141
+ // 1. A private /tmp FIRST, because it erases everything beneath it.
142
+ //
143
+ // This has to lead. The broker's socket directory is an `mkdtemp` under
144
+ // `os.tmpdir()`, and every staged contract input (backup_dir, restore_dir,
145
+ // the cross-module roots) comes from `stagingDirFor`, also under
146
+ // `os.tmpdir()`. Bind those first and the tmpfs wipes them.
147
+ //
148
+ // The failure that causes is not a crash. A hook whose `backup_dir` is
149
+ // silently an empty tmpfs directory writes into it, returns success, and
150
+ // produces a backup containing NOTHING. It is found at restore. So the gate
151
+ // on this asserts the artifact is non-empty, never that the hook exited zero.
152
+ entries.push(entry('/tmp', 'tmpfs', 'private scratch, per run'));
153
+
154
+ // 2. The runtime. Without it nothing runs, so it is not really a policy row.
155
+ entries.push(entry(request.runtimePath, 'ro', 'the interpreter'));
156
+ entries.push(entry(dirname(request.runnerPath), 'ro', 'the runner shim celilo spawns'));
157
+ for (const dir of RUNTIME_SUPPORT_DIRS) {
158
+ entries.push(entry(dir, 'ro', 'shared libraries and trust store'));
159
+ }
160
+
161
+ // 3. The module's own tree, read-only, then its writable directories carved
162
+ // on top. bubblewrap resolves that in the right order, which is why the
163
+ // order here is not cosmetic.
164
+ //
165
+ // D9 says "the module's own tree is bound read-only". These are the
166
+ // carved exceptions to that sentence, and there are three of them rather
167
+ // than the one D9's prose implies.
168
+ entries.push(entry(modulePath, 'ro', "the module's own tree"));
169
+ entries.push(
170
+ entry(resolve(request.stateDir), 'rw', 'ctx.stateDir, the sanctioned writable directory'),
171
+ );
172
+ entries.push(
173
+ entry(join(modulePath, 'generated'), 'rw', "celilo's generated output the hook may amend"),
174
+ );
175
+ if (request.screenshotDir) {
176
+ entries.push(entry(resolve(request.screenshotDir), 'rw', 'ctx.screenshotDir, this run only'));
177
+ }
178
+
179
+ // 4. The broker channel. Bound AFTER the tmpfs, per the note above.
180
+ entries.push(entry(resolve(request.socketDir), 'rw', 'the capability broker socket'));
181
+
182
+ // 5. Contract-declared path inputs, at the access the contract declares.
183
+ // Never inferred from the name — see ContractField.path.
184
+ for (const input of request.pathInputs) {
185
+ entries.push(
186
+ entry(
187
+ resolve(input.value),
188
+ input.access === 'write' ? 'rw' : 'ro',
189
+ `contract input '${input.name}' (${input.access})`,
190
+ ),
191
+ );
192
+ }
193
+
194
+ // 6. Stage 2 only. See MountSetRequest.sshDir.
195
+ if (request.sshDir) {
196
+ entries.push(
197
+ entry(
198
+ resolve(request.sshDir),
199
+ 'ro',
200
+ 'remote.ts needs the key until the broker holds it (D12)',
201
+ ),
202
+ );
203
+ }
204
+
205
+ return {
206
+ entries: entries.filter((e) => !isForbidden(e.path)),
207
+ // The module's own tree is the only directory guaranteed to exist inside
208
+ // the jail and to mean something to the hook.
209
+ chdir: modulePath,
210
+ };
211
+ }
212
+
213
+ /** Is this path one nothing may ever mount? See NEVER_MOUNT. */
214
+ export function isForbidden(path: string): boolean {
215
+ const resolved = isAbsolute(path) ? resolve(path) : path;
216
+ return NEVER_MOUNT.some((forbidden) => resolved === forbidden);
217
+ }
218
+
219
+ /** The forbidden list, for the test that asserts it is honoured. */
220
+ export function forbiddenPaths(): readonly string[] {
221
+ return NEVER_MOUNT;
222
+ }
223
+
224
+ /** Render a mount set as bubblewrap arguments, in order. */
225
+ export function toBwrapArgs(set: MountSet): string[] {
226
+ const args: string[] = [];
227
+ for (const e of set.entries) {
228
+ if (e.mode === 'tmpfs') args.push('--tmpfs', e.path);
229
+ else if (e.mode === 'rw') args.push('--bind', e.path, e.path);
230
+ else args.push('--ro-bind', e.path, e.path);
231
+ }
232
+ args.push('--chdir', set.chdir);
233
+ return args;
234
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Test fixture: a hook that exercises every shape a capability call can take
3
+ * across the process boundary.
4
+ *
5
+ * One test per SHAPE rather than per method: the broker is generic, so a
6
+ * per-method suite would prove the same thing thirty-seven times and drift the
7
+ * moment a capability gained a method.
8
+ */
9
+
10
+ import { defineHook, isMissingProviderInputError } from '@celilo/capabilities';
11
+
12
+ interface DemoCapability {
13
+ providerModuleId?: string;
14
+ version?: string;
15
+ echo(request: Record<string, unknown>): Promise<unknown>;
16
+ boom(request: Record<string, unknown>): Promise<unknown>;
17
+ missingInput(request: Record<string, unknown>): Promise<unknown>;
18
+ returnsNothing(request: Record<string, unknown>): Promise<unknown>;
19
+ /** Never implemented by this provider — the absent-optional-method case. */
20
+ sometimesAbsent?(request: Record<string, unknown>): Promise<unknown>;
21
+ }
22
+
23
+ export default defineHook({
24
+ hook: 'container_created',
25
+ requires: [],
26
+ handler: async (ctx) => {
27
+ // `demo` is a fixture capability, not a registry entry, so the typed
28
+ // capability map does not know it. The broker is generic and does not care.
29
+ const demo = (ctx.capabilities as unknown as Record<string, DemoCapability>).demo;
30
+ const outputs: Record<string, unknown> = {};
31
+
32
+ // Non-function properties are copied verbatim, which is what keeps
33
+ // `providerModuleId` readable — a hook names the provider in its errors.
34
+ outputs.providerModuleId = demo.providerModuleId;
35
+ outputs.version = demo.version;
36
+
37
+ // An optional method the provider did not implement must be ABSENT, not a
38
+ // proxy that throws, or `if (cap.registerTrustedSource)` answers wrongly.
39
+ outputs.optionalMethodAbsent = demo.sometimesAbsent === undefined;
40
+
41
+ outputs.returned = await demo.echo({ x: 1, nested: { y: [2, 3] } });
42
+ outputs.undefinedBecomesNull = await demo.returnsNothing({});
43
+
44
+ try {
45
+ await demo.boom({});
46
+ outputs.plainThrow = 'did not throw';
47
+ } catch (error) {
48
+ outputs.plainThrow = {
49
+ isError: error instanceof Error,
50
+ name: (error as Error).name,
51
+ message: (error as Error).message,
52
+ hasStack: typeof (error as Error).stack === 'string',
53
+ };
54
+ }
55
+
56
+ try {
57
+ await demo.missingInput({});
58
+ outputs.missingProviderInput = 'did not throw';
59
+ } catch (error) {
60
+ const e = error as Record<string, unknown>;
61
+ outputs.missingProviderInput = {
62
+ recognised: isMissingProviderInputError(error),
63
+ providerModuleId: e.providerModuleId,
64
+ ensureId: e.ensureId,
65
+ value: e.value,
66
+ humanContext: e.humanContext,
67
+ };
68
+ }
69
+
70
+ try {
71
+ await (demo as unknown as { nope(): Promise<unknown> }).nope();
72
+ outputs.unknownMethod = 'did not throw';
73
+ } catch (error) {
74
+ outputs.unknownMethod = (error as Error).message;
75
+ }
76
+
77
+ return outputs;
78
+ },
79
+ });
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Test fixture: a hook that outruns its total timeout and then writes a file.
3
+ *
4
+ * The marker is the harm, not the mechanism. `Promise.race` in the old
5
+ * executor rejected at the bound and cancelled nothing, so the hook kept its
6
+ * capability objects and went on doing work — registering DNS, opening ports —
7
+ * minutes after celilo reported the deploy failed. A test that only asserts
8
+ * the rejection cannot see that; it passed throughout (celilo#1003).
9
+ *
10
+ * The marker is written AFTER the sleep, so its existence after the bound is
11
+ * proof the hook was abandoned rather than killed.
12
+ */
13
+
14
+ import { writeFileSync } from 'node:fs';
15
+ import { defineHook } from '@celilo/capabilities';
16
+
17
+ export default defineHook({
18
+ hook: 'container_created',
19
+ requires: [],
20
+ handler: async (ctx) => {
21
+ ctx.logger.info('runaway hook starting');
22
+ await new Promise((r) => setTimeout(r, Number(ctx.config.sleep_ms ?? 2000)));
23
+ writeFileSync(String(ctx.config.marker_path), 'the hook kept running\n');
24
+ return {};
25
+ },
26
+ });
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Test fixture: a hook that traps SIGTERM and keeps going.
3
+ *
4
+ * SIGTERM is a request. This is the case that makes the grace period load
5
+ * bearing: without the SIGKILL that follows it, a hook can decline to die and
6
+ * the boundary buys nothing over the promise race it replaced.
7
+ */
8
+
9
+ import { writeFileSync } from 'node:fs';
10
+ import { defineHook } from '@celilo/capabilities';
11
+
12
+ export default defineHook({
13
+ hook: 'container_created',
14
+ requires: [],
15
+ handler: async (ctx) => {
16
+ process.on('SIGTERM', () => ctx.logger.info('declining to die'));
17
+ ctx.logger.info('sigterm-ignoring hook starting');
18
+ await new Promise((r) => setTimeout(r, Number(ctx.config.sleep_ms ?? 20_000)));
19
+ writeFileSync(String(ctx.config.marker_path), 'survived SIGTERM\n');
20
+ return {};
21
+ },
22
+ });
@@ -22,7 +22,7 @@
22
22
  * change and requires a v2.0 contract.
23
23
  */
24
24
 
25
- import type { HookName } from '@celilo/capabilities';
25
+ import type { HookName, PathAccess } from '@celilo/capabilities';
26
26
 
27
27
  /**
28
28
  * Per-input/output metadata.
@@ -33,6 +33,21 @@ import type { HookName } from '@celilo/capabilities';
33
33
  */
34
34
  export interface ContractField {
35
35
  required: boolean;
36
+ /**
37
+ * Set when the framework supplies a filesystem path in this field, and the
38
+ * access the hook is given to it. Absent means "not a path".
39
+ *
40
+ * The hook jail derives its bind mounts from this (design D9). Path-ness is
41
+ * NEVER inferred from the field name at runtime: a name heuristic silently
42
+ * changes behaviour the day somebody adds an input called `workspace`, and
43
+ * the symptom is an ENOENT on a path that visibly exists on the box.
44
+ *
45
+ * A field carrying a path-shaped value with no annotation here is a defect.
46
+ * `db_path` was one for months — passed by `backup-create.ts` and declared
47
+ * nowhere, so anything reasoning from this table was wrong about what a
48
+ * backup hook receives.
49
+ */
50
+ path?: { access: PathAccess };
36
51
  }
37
52
 
38
53
  /**
@@ -112,7 +127,7 @@ export const V1_HOOKS: ContractHooks = {
112
127
  },
113
128
  on_backup: {
114
129
  inputs: {
115
- backup_dir: { required: true },
130
+ backup_dir: { required: true, path: { access: 'write' } },
116
131
  /**
117
132
  * Path to a directory containing read-only mirrors of OTHER modules'
118
133
  * `generated/terraform/` trees, plus an `index.json` enumerating
@@ -136,7 +151,7 @@ export const V1_HOOKS: ContractHooks = {
136
151
  * terraform.tfstate
137
152
  * terraform.tfstate.backup
138
153
  */
139
- cross_module_root: { required: false },
154
+ cross_module_root: { required: false, path: { access: 'read' } },
140
155
  },
141
156
  outputs: {
142
157
  artifact_count: { required: true },
@@ -146,7 +161,7 @@ export const V1_HOOKS: ContractHooks = {
146
161
  },
147
162
  on_backup_analyze: {
148
163
  inputs: {
149
- artifact_path: { required: true },
164
+ artifact_path: { required: true, path: { access: 'read' } },
150
165
  },
151
166
  outputs: {
152
167
  artifact_count: { required: true },
@@ -156,7 +171,7 @@ export const V1_HOOKS: ContractHooks = {
156
171
  },
157
172
  on_restore: {
158
173
  inputs: {
159
- restore_dir: { required: true },
174
+ restore_dir: { required: true, path: { access: 'read' } },
160
175
  schema_version: { required: true },
161
176
  /**
162
177
  * Path to a writable staging directory the framework atomically
@@ -170,7 +185,7 @@ export const V1_HOOKS: ContractHooks = {
170
185
  * a single rename + cleanup of any stale files, so a partial
171
186
  * write (hook crashed mid-restore) leaves the live state intact.
172
187
  */
173
- cross_module_write_root: { required: false },
188
+ cross_module_write_root: { required: false, path: { access: 'write' } },
174
189
  },
175
190
  outputs: {
176
191
  restored_items: { required: true },
@@ -0,0 +1,61 @@
1
+ /**
2
+ * A module may not declare a provider view.
3
+ *
4
+ * `web_routes` and `firewall_registry` are in `KNOWN_CAPABILITY_NAMES`, so the
5
+ * name check that asks only "is this known?" accepts them. celilo injects both
6
+ * into the hooks of the module that PROVIDES the paired capability and never
7
+ * to a consumer, so a module requiring one would validate, publish, deploy, and
8
+ * then find the capability simply absent at hook time — with a pre-flight error
9
+ * naming a missing provider that was never going to exist.
10
+ *
11
+ * Registering the two names (celilo#1007) is what created this opening. They
12
+ * were invisible to every check before that, which was its own, worse problem.
13
+ */
14
+
15
+ import { describe, expect, test } from 'bun:test';
16
+ import { PROVIDER_VIEW_CAPABILITIES } from '@celilo/capabilities';
17
+ import type { ModuleManifest } from './schema';
18
+ import { validateCapabilityNames } from './validate';
19
+
20
+ function manifestRequiring(name: string): ModuleManifest {
21
+ return {
22
+ requires: { capabilities: [{ name, version: '1.0.0' }] },
23
+ } as unknown as ModuleManifest;
24
+ }
25
+
26
+ function manifestOptionally(name: string): ModuleManifest {
27
+ return {
28
+ requires: { capabilities: [] },
29
+ optional: { capabilities: [{ name, version: '1.0.0' }] },
30
+ } as unknown as ModuleManifest;
31
+ }
32
+
33
+ describe('provider views are not declarable', () => {
34
+ for (const view of PROVIDER_VIEW_CAPABILITIES) {
35
+ test(`requires.capabilities rejects '${view}'`, () => {
36
+ const result = validateCapabilityNames(manifestRequiring(view));
37
+ expect(result).not.toBeNull();
38
+ expect(result?.errors[0].message).toContain('provider view');
39
+ });
40
+
41
+ test(`optional.capabilities rejects '${view}' too`, () => {
42
+ // The optional path is checked for the same reason the privileged-
43
+ // capability check covers it: otherwise the declaration is smuggled in
44
+ // through the soft-require door.
45
+ const result = validateCapabilityNames(manifestOptionally(view));
46
+ expect(result).not.toBeNull();
47
+ expect(result?.errors[0].message).toContain('provider view');
48
+ });
49
+
50
+ test(`'${view}' is not offered in the suggestion list`, () => {
51
+ // The message for a genuine typo lists what an author CAN require, so a
52
+ // view must not appear there either.
53
+ const result = validateCapabilityNames(manifestRequiring('definitely_not_a_capability'));
54
+ expect(result?.errors[0].message).not.toContain(view);
55
+ });
56
+ }
57
+
58
+ test('a real capability still validates', () => {
59
+ expect(validateCapabilityNames(manifestRequiring('public_web'))).toBeNull();
60
+ });
61
+ });
@@ -1,4 +1,4 @@
1
- import { KNOWN_CAPABILITY_NAMES } from '@celilo/capabilities';
1
+ import { KNOWN_CAPABILITY_NAMES, isProviderView } from '@celilo/capabilities';
2
2
  import { parse as parseYaml } from 'yaml';
3
3
  import type { ZodError } from 'zod';
4
4
  import { validateModuleZoneRequirements } from '../services/zone-policy';
@@ -146,24 +146,31 @@ export function validateCapabilityRequirements(
146
146
  */
147
147
  export function validateCapabilityNames(manifest: ModuleManifest): ValidationError | null {
148
148
  const errors: Array<{ path: string; message: string }> = [];
149
- const knownNames: readonly string[] = KNOWN_CAPABILITY_NAMES;
149
+ // Provider views are registry entries, so a bare membership test would accept
150
+ // them. They are framework-injected into the PROVIDER's own hooks and no
151
+ // module can ask for one, so requiring one is always a mistake, and a silent
152
+ // one: the manifest would validate and the capability would simply never
153
+ // arrive. Excluded from the suggestion list too, for the same reason.
154
+ const requirableNames: readonly string[] = KNOWN_CAPABILITY_NAMES.filter(
155
+ (name) => !isProviderView(name),
156
+ );
157
+
158
+ const checkName = (name: string, path: string): void => {
159
+ if (requirableNames.includes(name)) return;
160
+ errors.push({
161
+ path,
162
+ message: isProviderView(name)
163
+ ? `'${name}' is a provider view, not a capability a module can declare. celilo injects it into the hooks of the module that PROVIDES the paired capability. Remove this declaration.`
164
+ : `Unknown capability '${name}'. Known capabilities: ${requirableNames.join(', ')}.`,
165
+ });
166
+ };
150
167
 
151
168
  for (const required of manifest.requires.capabilities) {
152
- if (!knownNames.includes(required.name)) {
153
- errors.push({
154
- path: `requires.capabilities.${required.name}`,
155
- message: `Unknown capability '${required.name}'. Known capabilities: ${knownNames.join(', ')}.`,
156
- });
157
- }
169
+ checkName(required.name, `requires.capabilities.${required.name}`);
158
170
  }
159
171
 
160
172
  for (const opt of manifest.optional?.capabilities ?? []) {
161
- if (!knownNames.includes(opt.name)) {
162
- errors.push({
163
- path: `optional.capabilities.${opt.name}`,
164
- message: `Unknown capability '${opt.name}'. Known capabilities: ${knownNames.join(', ')}.`,
165
- });
166
- }
173
+ checkName(opt.name, `optional.capabilities.${opt.name}`);
167
174
  }
168
175
 
169
176
  if (errors.length > 0) {
@@ -0,0 +1,105 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { moduleStateDir } from '@celilo/capabilities';
6
+ import { moduleIntegrity, modules } from '../../db/schema';
7
+ import { cleanupTestDatabase, setupTestDatabase } from '../../test-utils/database';
8
+ import { auditModule } from './audit';
9
+ import { classifyModulePath } from './package-rules';
10
+
11
+ /**
12
+ * The recurrence gate for celilo#1000: a hook has a sanctioned place to write,
13
+ * and what it writes there never becomes a `module audit` finding.
14
+ *
15
+ * **The names below are generated, deliberately.** The failure this gate exists
16
+ * for is not "we forgot to allow `state/cursor.json`". It is that the allow-list
17
+ * was a list of literals (`screenshots/`, `cookies.json`) patched in one at a
18
+ * time after each one bit someone, so it could only ever cover filenames
19
+ * somebody had already been surprised by. A test asserting a literal filename
20
+ * reproduces exactly that weakness. A hook writes what it needs to write, and
21
+ * the framework does not get to know the name in advance.
22
+ */
23
+
24
+ /** A name nothing in the codebase anticipates, and that no allow-list can hold. */
25
+ function unanticipatedName(seed: number): string {
26
+ return `${seed.toString(36)}-${(seed * 7919).toString(36)}.dat`;
27
+ }
28
+
29
+ describe('celilo#1000: state/ is the hook-writable directory', () => {
30
+ test('any name a hook invents under state/ is derived, at any depth', () => {
31
+ for (let seed = 1; seed <= 25; seed++) {
32
+ const name = unanticipatedName(seed);
33
+ for (const path of [`state/${name}`, `state/nested/${name}`, `state/a/b/c/${name}`]) {
34
+ expect(`${path} => ${classifyModulePath(path)}`).toBe(`${path} => derived`);
35
+ }
36
+ }
37
+ });
38
+
39
+ /**
40
+ * The contrast is the point, and `package` rather than `unknown` is what the
41
+ * contrast actually is. `classifyModulePath` defaults to `package`, meaning
42
+ * "this belongs to the module and must match `checksums.json`", so the same
43
+ * name one directory up is scanned, found absent from the checksums, and
44
+ * reported. That is the reporting this change exempts `state/` from, and
45
+ * exempts nothing else from. If this half ever goes green alongside the half
46
+ * above, the fix widened rather than named.
47
+ */
48
+ test('the same names outside state/ are still checksum-bearing', () => {
49
+ for (let seed = 1; seed <= 25; seed++) {
50
+ const name = unanticipatedName(seed);
51
+ expect(`${name} => ${classifyModulePath(name)}`).toBe(`${name} => package`);
52
+ expect(`lib/${name} => ${classifyModulePath(`lib/${name}`)}`).toBe(`lib/${name} => package`);
53
+ }
54
+ });
55
+
56
+ test('a hook writing into state/ leaves module audit clean', async () => {
57
+ const db = await setupTestDatabase();
58
+ const root = mkdtempSync(join(tmpdir(), 'celilo-state-gate-'));
59
+ try {
60
+ // A minimal installed tree: one packaged file, recorded in checksums.
61
+ writeFileSync(join(root, 'manifest.yml'), 'id: state-gate\nversion: 1.0.0\n');
62
+ db.insert(modules)
63
+ .values({
64
+ id: 'state-gate',
65
+ name: 'state-gate',
66
+ version: '1.0.0',
67
+ sourcePath: root,
68
+ manifestData: { id: 'state-gate', version: '1.0.0' },
69
+ })
70
+ .run();
71
+ db.insert(moduleIntegrity)
72
+ .values({
73
+ moduleId: 'state-gate',
74
+ checksums: { 'manifest.yml': await xxhashOf(join(root, 'manifest.yml')) },
75
+ version: '1.0.0',
76
+ })
77
+ .run();
78
+
79
+ const before = await auditModule('state-gate', db);
80
+ expect(before.violations).toEqual([]);
81
+
82
+ // Now a hook runs and writes something nobody declared. The path comes
83
+ // from the framework, NOT from this test: a gate that hand-rolls
84
+ // `join(root, 'state')` proves the audit tolerates a directory while
85
+ // saying nothing about whether a module can find it, which is exactly
86
+ // how the surface half of celilo#1000 shipped missing.
87
+ const stateDir = moduleStateDir(root);
88
+ mkdirSync(stateDir, { recursive: true });
89
+ writeFileSync(join(stateDir, unanticipatedName(42)), 'whatever the hook needed');
90
+
91
+ const after = await auditModule('state-gate', db);
92
+ expect(after.violations).toEqual([]);
93
+ expect(after.success).toBe(true);
94
+ } finally {
95
+ rmSync(root, { recursive: true, force: true });
96
+ await cleanupTestDatabase(db);
97
+ }
98
+ });
99
+ });
100
+
101
+ /** The audit's own hash, so the fixture's checksum is right by construction. */
102
+ async function xxhashOf(path: string): Promise<string> {
103
+ const { readFileSync } = await import('node:fs');
104
+ return Bun.hash.xxHash64(readFileSync(path)).toString(16);
105
+ }
@@ -94,8 +94,16 @@ export function classifyModulePath(relPath: string): ModulePathClass {
94
94
  if (name === 'tsconfig.json') return 'unknown';
95
95
  if (name.endsWith('.netapp') || name.endsWith('.test.ts')) return 'unknown';
96
96
 
97
- // Celilo's own output under the module's install root.
98
- if (segments[0] === 'generated' || segments[0] === 'screenshots') return 'derived';
97
+ // Celilo's own output under the module's install root, plus the one directory
98
+ // a MODULE may write to. `state/` is celilo#1000: hooks had nowhere sanctioned
99
+ // to put anything, so whatever they wrote surfaced as an `extra` finding, and
100
+ // the two entries beside it here (`screenshots/`, `cookies.json`) are what
101
+ // that looked like being solved one filename at a time. `derived` already
102
+ // means exactly what a scratch location needs (writable, survives `module
103
+ // update`, not audited, not pruned), so this names a directory rather than
104
+ // adding machinery.
105
+ if (segments[0] === 'generated' || segments[0] === 'screenshots' || segments[0] === 'state')
106
+ return 'derived';
99
107
  // A checksum manifest cannot list itself, nor the signature over it.
100
108
  if (relPath === 'checksums.json' || relPath === 'signature.sig') return 'derived';
101
109
  // Regenerated by `module import` from the manifest (HOOK_API_V2 Phase 2).