@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.
- package/CELILO_CORE_MODULES.md +2 -0
- package/CELILO_SUBSYSTEMS.md +2 -0
- package/drizzle/0028_capability_bindings.sql +26 -0
- package/drizzle/0029_module_instances.sql +58 -0
- package/drizzle/meta/_journal.json +14 -0
- package/package.json +2 -2
- package/src/cli/commands/module-show.ts +1 -0
- package/src/db/foreign-keys.test.ts +101 -0
- package/src/db/schema.ts +161 -5
- package/src/hooks/broker.test.ts +153 -0
- package/src/hooks/broker.ts +307 -0
- package/src/hooks/capability-loader-bindings.test.ts +163 -0
- package/src/hooks/capability-loader-firewall.test.ts +108 -0
- package/src/hooks/capability-loader.test.ts +10 -2
- package/src/hooks/capability-loader.ts +59 -2
- package/src/hooks/define-hook.test.ts +1 -0
- package/src/hooks/executor.test.ts +7 -0
- package/src/hooks/executor.ts +245 -111
- package/src/hooks/hook-protocol.test.ts +192 -0
- package/src/hooks/hook-protocol.ts +275 -0
- package/src/hooks/hook-runner.ts +231 -0
- package/src/hooks/hook-state-dir.test.ts +109 -0
- package/src/hooks/hook-timeout.test.ts +104 -0
- package/src/hooks/hook-trespass.test.ts +202 -0
- package/src/hooks/injected-capabilities.test.ts +75 -0
- package/src/hooks/mount-set.test.ts +148 -0
- package/src/hooks/mount-set.ts +234 -0
- package/src/hooks/test-fixtures/capability-calling-hook.ts +79 -0
- package/src/hooks/test-fixtures/runaway-hook.ts +26 -0
- package/src/hooks/test-fixtures/sigterm-ignoring-hook.ts +22 -0
- package/src/manifest/contracts/v1.ts +21 -6
- package/src/manifest/validate-provider-views.test.ts +61 -0
- package/src/manifest/validate.ts +21 -14
- package/src/module/packaging/module-state-directory.test.ts +105 -0
- package/src/module/packaging/package-rules.ts +10 -2
- package/src/policy/capability-shape-baseline.ts +8 -0
- package/src/policy/capability-shape.ts +13 -1
- package/src/policy/module-business-baseline.ts +36 -0
- package/src/policy/module-dep-reachability.test.ts +167 -0
- package/src/services/alerting/ack.test.ts +2 -2
- package/src/services/alerting/deferral.test.ts +2 -2
- package/src/services/alerting/delivery-loop.test.ts +2 -2
- package/src/services/alerting/deploy-hooks.test.ts +2 -2
- package/src/services/alerting/inbound-poller.test.ts +2 -2
- package/src/services/alerting/inbound.test.ts +2 -2
- package/src/services/alerting/notification-responder.test.ts +2 -2
- package/src/services/alerting/run-monitor.test.ts +2 -2
- package/src/services/alerting/store.test.ts +2 -2
- package/src/services/alerting/sweep-runner.test.ts +2 -2
- package/src/services/alerting/tokens.test.ts +2 -2
- package/src/services/capability-bindings.test.ts +104 -0
- package/src/services/capability-bindings.ts +107 -0
- package/src/services/capability-table-rows.test.ts +2 -2
- package/src/services/consumer-cleanup.test.ts +40 -3
- package/src/services/dns-internal-records.test.ts +3 -3
- package/src/services/fleet-checks.test.ts +4 -4
- package/src/services/module-instances.test.ts +198 -0
- package/src/services/module-instances.ts +96 -0
- package/src/services/module-journal.test.ts +2 -2
- package/src/services/module-subscriptions.test.ts +1 -1
- package/src/services/port-forwards.test.ts +2 -2
- package/src/services/trusted-sources.test.ts +3 -3
- package/src/templates/ingress-ip.test.ts +31 -0
- package/src/test-utils/database.ts +31 -1
- package/src/test-utils/module-fixtures.ts +147 -35
- 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
|
+
});
|
package/src/manifest/validate.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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).
|