@celilo/cli 1.11.0 → 1.13.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.
- package/CELILO_CORE_MODULES.md +2 -1
- package/CELILO_SUBSYSTEMS.md +17 -2
- package/package.json +3 -3
- package/src/cli/commands/alerts-list.ts +16 -1
- package/src/cli/commands/backup-list.test.ts +82 -1
- package/src/cli/commands/backup-list.ts +113 -4
- package/src/cli/commands/console.ts +122 -0
- package/src/cli/commands/module-list.ts +3 -41
- package/src/cli/commands/module-publish.ts +2 -0
- package/src/cli/completion.ts +5 -0
- package/src/cli/index.ts +25 -1
- package/src/console/closure.test.ts +246 -0
- package/src/console/closure.ts +208 -0
- package/src/console/control-plane-boundary.test.ts +75 -0
- package/src/console/projection.test.ts +231 -0
- package/src/console/projection.ts +327 -0
- package/src/db/schema.ts +19 -14
- package/src/hooks/broker.test.ts +4 -6
- package/src/hooks/executor.test.ts +85 -4
- package/src/hooks/executor.ts +164 -9
- package/src/hooks/hook-jail-unreachability.test.ts +173 -0
- package/src/hooks/hook-state-dir.test.ts +14 -2
- package/src/hooks/hook-timeout.test.ts +2 -4
- package/src/hooks/hook-trespass.test.ts +50 -5
- package/src/hooks/jail.test.ts +370 -0
- package/src/hooks/jail.ts +491 -0
- package/src/hooks/mount-set.ts +24 -0
- package/src/hooks/test-fixtures/jail-probe-hook.ts +59 -0
- package/src/manifest/icon-schema.test.ts +48 -0
- package/src/manifest/schema.ts +92 -0
- package/src/manifest/validate.test.ts +142 -0
- package/src/manifest/validate.ts +101 -0
- package/src/module/import.test.ts +116 -0
- package/src/module/import.ts +73 -1
- package/src/module/packaging/audit.ts +103 -1
- package/src/module/packaging/classify-module-path.test.ts +36 -0
- package/src/module/packaging/package-rules.ts +18 -0
- package/src/policy/capability-shape-baseline.ts +8 -0
- package/src/policy/module-business-baseline.ts +12 -0
- package/src/registry/client.ts +9 -0
- package/src/services/alerting/observed-health.ts +71 -0
- package/src/services/api-principal-enrolment.test.ts +179 -0
- package/src/services/api-principal-enrolment.ts +103 -0
- package/src/services/audit/backups.ts +10 -1
- package/src/services/backup-metadata.ts +19 -11
- package/src/services/consumer-cleanup.ts +31 -5
- package/src/services/instance-ops.test.ts +302 -0
- package/src/services/instance-ops.ts +292 -0
- package/src/services/module-instances.test.ts +428 -42
- package/src/services/module-instances.ts +219 -26
|
@@ -0,0 +1,491 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The hook jail's caller (openspec/changes/hook-process-boundary, D8 and D9).
|
|
3
|
+
*
|
|
4
|
+
* `mount-set.ts` computes WHAT a hook may see. This file decides whether a jail
|
|
5
|
+
* is available at all, turns that computation into a command line, and records
|
|
6
|
+
* which of the two happened. It is the half that touches the machine, kept out
|
|
7
|
+
* of the derivation so the derivation stays hermetic.
|
|
8
|
+
*
|
|
9
|
+
* Three things live here and they answer three different questions:
|
|
10
|
+
*
|
|
11
|
+
* - `detectJailBackend()` — CAN this host jail? Measured by running
|
|
12
|
+
* bubblewrap, never by looking for the binary. D8's table records four
|
|
13
|
+
* distinct denials that all leave `bwrap` sitting on disk.
|
|
14
|
+
* - `planJailedSpawn()` — pure. Given a backend, a policy and a mount set,
|
|
15
|
+
* what command does celilo spawn? (Rule 10.4.)
|
|
16
|
+
* - `recordJailMode()` — WHICH happened, written down. D8 is explicit that
|
|
17
|
+
* the mode is state and not a log line: a per-invocation warning on a fleet
|
|
18
|
+
* that deploys often is noise, noise gets filtered, and filtered is
|
|
19
|
+
* indistinguishable from absent. The row that matters is a host that used
|
|
20
|
+
* to jail and has stopped, and you cannot see a transition in a log nobody
|
|
21
|
+
* reads.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { execFileSync } from 'node:child_process';
|
|
25
|
+
import type { Dirent } from 'node:fs';
|
|
26
|
+
import {
|
|
27
|
+
existsSync,
|
|
28
|
+
mkdirSync,
|
|
29
|
+
readFileSync,
|
|
30
|
+
readdirSync,
|
|
31
|
+
realpathSync,
|
|
32
|
+
renameSync,
|
|
33
|
+
statSync,
|
|
34
|
+
writeFileSync,
|
|
35
|
+
} from 'node:fs';
|
|
36
|
+
import { hostname } from 'node:os';
|
|
37
|
+
import { dirname, join } from 'node:path';
|
|
38
|
+
import { getDataDir } from '../config/paths';
|
|
39
|
+
import { type MountSet, type MountSetRequest, toBwrapArgs } from './mount-set';
|
|
40
|
+
|
|
41
|
+
/** Which jail celilo can build here. `none` means the hook runs unjailed. */
|
|
42
|
+
export type JailBackend = 'bubblewrap' | 'none';
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* What the operator asked for. `CELILO_HOOK_JAIL`, and the default is `auto`.
|
|
46
|
+
*
|
|
47
|
+
* `required` is D8's end state, reached as an explicit act once stage 2 has
|
|
48
|
+
* been proven on a host: an unavailable jail becomes a hard failure rather
|
|
49
|
+
* than a recorded fact. `off` is the escape hatch for the reverse case — a
|
|
50
|
+
* hook that has not yet been walked against the jail (task 4.13) and needs to
|
|
51
|
+
* run today.
|
|
52
|
+
*/
|
|
53
|
+
export type JailPolicy = 'auto' | 'off' | 'required';
|
|
54
|
+
|
|
55
|
+
/** Whether the hook that just ran was jailed. The recorded state (D8). */
|
|
56
|
+
export type JailMode = 'jailed' | 'unjailed';
|
|
57
|
+
|
|
58
|
+
export interface JailAvailability {
|
|
59
|
+
readonly backend: JailBackend;
|
|
60
|
+
/**
|
|
61
|
+
* Why there is no backend, in one sentence an operator can act on.
|
|
62
|
+
* Absent when there is one. `celilo system doctor` surfaces it (task 4.6).
|
|
63
|
+
*/
|
|
64
|
+
readonly reason?: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface JailPlan {
|
|
68
|
+
/** The command celilo spawns, jail wrapper included. */
|
|
69
|
+
readonly cmd: readonly string[];
|
|
70
|
+
readonly mode: JailMode;
|
|
71
|
+
readonly backend: JailBackend;
|
|
72
|
+
readonly reason?: string;
|
|
73
|
+
/**
|
|
74
|
+
* Mount rows the derivation asked for that do not exist on this host.
|
|
75
|
+
*
|
|
76
|
+
* bubblewrap fails the whole jail on a bind whose SOURCE is missing, and
|
|
77
|
+
* several rows are legitimately absent: `/lib64` does not exist on arm64,
|
|
78
|
+
* `<module>/generated` only appears once celilo has generated something, and
|
|
79
|
+
* `~/.ssh` need not exist at all. Dropping them is the caller's job rather
|
|
80
|
+
* than the derivation's, which is why they are reported rather than silently
|
|
81
|
+
* filtered — a contract input landing here is a real defect and this is
|
|
82
|
+
* where it becomes visible.
|
|
83
|
+
*/
|
|
84
|
+
readonly skipped: readonly string[];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Namespace flags, shared by the probe and the real spawn so they cannot
|
|
89
|
+
* drift.
|
|
90
|
+
*
|
|
91
|
+
* A probe that clears a weaker bar than the spawn is the failure CLAUDE.md
|
|
92
|
+
* names: a correct assertion about the wrong subject. If any flag here is
|
|
93
|
+
* denied, the probe fails and celilo records `unjailed` instead of discovering
|
|
94
|
+
* it one hook at a time.
|
|
95
|
+
*
|
|
96
|
+
* **`--unshare-net` is deliberately absent.** D9 says the network is not
|
|
97
|
+
* namespaced; D12 scopes reachability by withholding the credential instead.
|
|
98
|
+
*
|
|
99
|
+
* `--unshare-pid` needs a fresh `/proc` or the jail shows the host's process
|
|
100
|
+
* table, and `/proc/1/root` is a well-worn way to read out of one. It also
|
|
101
|
+
* makes the kill in D7 total: `bwrap` is pid 1 inside the namespace, and the
|
|
102
|
+
* kernel reaps every process in a pid namespace whose init dies.
|
|
103
|
+
*
|
|
104
|
+
* `--new-session` is NOT here. It defends against TIOCSTI injection into a
|
|
105
|
+
* controlling terminal, and the child is spawned with `stdout: 'pipe'` and no
|
|
106
|
+
* tty, so there is nothing to inject into.
|
|
107
|
+
*/
|
|
108
|
+
const JAIL_NAMESPACE_ARGS = [
|
|
109
|
+
'--unshare-user',
|
|
110
|
+
'--unshare-ipc',
|
|
111
|
+
'--unshare-pid',
|
|
112
|
+
'--unshare-uts',
|
|
113
|
+
// The one unshare that legitimately may be unavailable on an older kernel.
|
|
114
|
+
'--unshare-cgroup-try',
|
|
115
|
+
'--die-with-parent',
|
|
116
|
+
'--proc',
|
|
117
|
+
'/proc',
|
|
118
|
+
'--dev',
|
|
119
|
+
'/dev',
|
|
120
|
+
] as const;
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The jail builder.
|
|
124
|
+
*
|
|
125
|
+
* Resolved from `PATH` rather than pinned to `/usr/bin/bwrap`. The AppArmor
|
|
126
|
+
* profile D8 ships attaches BY PATH, so a `bwrap` found somewhere else carries
|
|
127
|
+
* no profile — but that case fails the probe below rather than passing
|
|
128
|
+
* silently, and celilo then records `unjailed` with the parser's own message.
|
|
129
|
+
* A visible wrong answer is worth more than a pinned path that is wrong on a
|
|
130
|
+
* distribution nobody tested.
|
|
131
|
+
*/
|
|
132
|
+
const BWRAP = 'bwrap';
|
|
133
|
+
|
|
134
|
+
/** How long to wait for the probe before calling the backend unavailable. */
|
|
135
|
+
const PROBE_TIMEOUT_MS = 10_000;
|
|
136
|
+
|
|
137
|
+
const JAIL_MODE_FILE = 'hook-jail-mode.json';
|
|
138
|
+
|
|
139
|
+
/** Cleared by nothing: a host does not gain a jail mid-process. */
|
|
140
|
+
let probed: JailAvailability | undefined;
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Can this host build a jail? Measured, cached for the process.
|
|
144
|
+
*
|
|
145
|
+
* The probe RUNS bubblewrap, with the same namespace flags the real spawn
|
|
146
|
+
* uses, against a whole-filesystem bind. Presence is not the question: D8
|
|
147
|
+
* measured four distinct denials — a missing `CAP_SYS_ADMIN`, a seccomp
|
|
148
|
+
* filter, an AppArmor policy, and Docker's masked `/proc` — and every one of
|
|
149
|
+
* them leaves the binary exactly where it was.
|
|
150
|
+
*
|
|
151
|
+
* `bun` is the command because it is the one binary guaranteed to be here: we
|
|
152
|
+
* are running in it. A probe that execs `/bin/true` fails for a missing
|
|
153
|
+
* `/bin/true` and reads exactly like a denied namespace.
|
|
154
|
+
*/
|
|
155
|
+
export function detectJailBackend(): JailAvailability {
|
|
156
|
+
if (!probed) probed = probeJailBackend();
|
|
157
|
+
return probed;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function probeJailBackend(): JailAvailability {
|
|
161
|
+
if (process.platform === 'darwin') {
|
|
162
|
+
// `sandbox-exec` is measured working (D8) but the backend that generates
|
|
163
|
+
// its profile is task 4.8 and is not built. Claiming macOS is jailed
|
|
164
|
+
// because the tool exists would be the fail-open jail D8 calls worse than
|
|
165
|
+
// no jail at all.
|
|
166
|
+
return {
|
|
167
|
+
backend: 'none',
|
|
168
|
+
reason:
|
|
169
|
+
'macOS has no hook jail yet: the sandbox-exec backend is designed but not built (hook-process-boundary task 4.8). Hooks run unjailed on this host.',
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
if (process.platform !== 'linux') {
|
|
173
|
+
return {
|
|
174
|
+
backend: 'none',
|
|
175
|
+
reason: `No hook jail backend exists for platform '${process.platform}'. Hooks run unjailed on this host.`,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
try {
|
|
180
|
+
execFileSync(
|
|
181
|
+
BWRAP,
|
|
182
|
+
[...JAIL_NAMESPACE_ARGS, '--ro-bind', '/', '/', '--', process.execPath, '--version'],
|
|
183
|
+
{ stdio: 'ignore', timeout: PROBE_TIMEOUT_MS },
|
|
184
|
+
);
|
|
185
|
+
return { backend: 'bubblewrap' };
|
|
186
|
+
} catch (error) {
|
|
187
|
+
return { backend: 'none', reason: unavailableReason(error) };
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Turn the probe's failure into something an operator can act on.
|
|
193
|
+
*
|
|
194
|
+
* Deliberately reads the FAILURE and not a message: D8 records one string
|
|
195
|
+
* (`setting up uid map: Permission denied`) arriving from two unrelated
|
|
196
|
+
* causes, which is why the guidance names both rather than guessing.
|
|
197
|
+
*/
|
|
198
|
+
function unavailableReason(error: unknown): string {
|
|
199
|
+
const spawnFailure = (error as { code?: string } | null)?.code;
|
|
200
|
+
if (spawnFailure === 'ENOENT') {
|
|
201
|
+
return 'bubblewrap is not installed, so hooks run unjailed. Install it (`apt install bubblewrap`) and re-run.';
|
|
202
|
+
}
|
|
203
|
+
return [
|
|
204
|
+
'bubblewrap is installed but could not build a namespace, so hooks run unjailed.',
|
|
205
|
+
'On Ubuntu 24.04 this is kernel.apparmor_restrict_unprivileged_userns=1 refusing a user namespace to an unprofiled binary;',
|
|
206
|
+
'celilo ships /etc/apparmor.d/celilo-hook-jail to grant it, so check that the profile loaded (`apparmor_parser -Q --skip-cache /etc/apparmor.d/celilo-hook-jail`).',
|
|
207
|
+
'Inside a container it is more likely a dropped capability or a masked /proc.',
|
|
208
|
+
].join(' ');
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* What the operator asked for, from `CELILO_HOOK_JAIL`.
|
|
213
|
+
*
|
|
214
|
+
* Fails fast on a value it does not know (Rule 4.2). A typo'd
|
|
215
|
+
* `CELILO_HOOK_JAIL=requried` that silently meant `auto` would read as the
|
|
216
|
+
* jail being enforced when it is not, which is the one mistake this variable
|
|
217
|
+
* exists to prevent.
|
|
218
|
+
*/
|
|
219
|
+
export function jailPolicy(): JailPolicy {
|
|
220
|
+
const raw = process.env.CELILO_HOOK_JAIL;
|
|
221
|
+
if (raw === undefined || raw === '') return 'auto';
|
|
222
|
+
if (raw === 'auto' || raw === 'off' || raw === 'required') return raw;
|
|
223
|
+
throw new Error(
|
|
224
|
+
`CELILO_HOOK_JAIL='${raw}' is not a hook jail policy. Use 'auto' (jail when a backend is available), 'required' (an unavailable jail is a hard failure), or 'off'.`,
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Resolve every path in a mount-set request through the filesystem (task 4.2k).
|
|
230
|
+
*
|
|
231
|
+
* `deriveMountSet` is pure and says so: it resolves lexically, which cannot
|
|
232
|
+
* follow a symlink. Two reasons the caller has to.
|
|
233
|
+
*
|
|
234
|
+
* `modulePath` is `mod.sourcePath` out of the database, and a database
|
|
235
|
+
* restored from another box carries that box's absolute paths (ISS-0052,
|
|
236
|
+
* `restore-from-file.ts`).
|
|
237
|
+
*
|
|
238
|
+
* And on macOS the whole jail turns on it: `/tmp` is a symlink to
|
|
239
|
+
* `/private/tmp`, a `sandbox-exec` rule naming the unresolved path is silently
|
|
240
|
+
* not applied, the access succeeds, and nothing reports an error (D8,
|
|
241
|
+
* measured). That backend is task 4.8, but the resolution belongs here now so
|
|
242
|
+
* it is not a thing 4.8 has to remember.
|
|
243
|
+
*
|
|
244
|
+
* A path that does not exist keeps its lexical form. It cannot be resolved and
|
|
245
|
+
* it will not survive `planJailedSpawn`'s existence filter either.
|
|
246
|
+
*/
|
|
247
|
+
export function realpathRequest(request: MountSetRequest): MountSetRequest {
|
|
248
|
+
return {
|
|
249
|
+
...request,
|
|
250
|
+
modulePath: realpathOrSelf(request.modulePath),
|
|
251
|
+
stateDir: realpathOrSelf(request.stateDir),
|
|
252
|
+
screenshotDir: request.screenshotDir ? realpathOrSelf(request.screenshotDir) : undefined,
|
|
253
|
+
socketDir: realpathOrSelf(request.socketDir),
|
|
254
|
+
runtimePath: realpathOrSelf(request.runtimePath),
|
|
255
|
+
runnerPath: realpathOrSelf(request.runnerPath),
|
|
256
|
+
runtimeModulePaths: request.runtimeModulePaths?.map(realpathOrSelf),
|
|
257
|
+
pathInputs: request.pathInputs.map((input) => ({
|
|
258
|
+
...input,
|
|
259
|
+
value: realpathOrSelf(input.value),
|
|
260
|
+
})),
|
|
261
|
+
sshDir: request.sshDir ? realpathOrSelf(request.sshDir) : undefined,
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* The `node_modules` directories the runner shim resolves its own imports
|
|
267
|
+
* through, nearest first.
|
|
268
|
+
*
|
|
269
|
+
* The shim is not self-contained: it imports `isCompiledHook` from
|
|
270
|
+
* `@celilo/capabilities` and Zod through `hook-protocol.ts`. Node resolution
|
|
271
|
+
* walks up from the importing file looking for `node_modules` at each
|
|
272
|
+
* ancestor, so this walks the same ladder and keeps whichever rungs exist.
|
|
273
|
+
*
|
|
274
|
+
* **It collects only directories literally named `node_modules`, and that is
|
|
275
|
+
* the safety property.** Under an npm install the shim sits at
|
|
276
|
+
* `/var/celilo/node_modules/@celilo/cli/src/hooks/`, so the ancestor holding
|
|
277
|
+
* its dependencies is `/var/celilo` — and binding THAT would put `master.key`
|
|
278
|
+
* and `celilo.db` inside the jail, which is the one outcome the whole change
|
|
279
|
+
* exists to prevent. Appending `node_modules` before testing for existence is
|
|
280
|
+
* what makes the difference, so do not "simplify" this into binding the
|
|
281
|
+
* ancestor itself.
|
|
282
|
+
*/
|
|
283
|
+
export function runtimeModulePathsFor(
|
|
284
|
+
runnerPath: string,
|
|
285
|
+
exists: (path: string) => boolean = existsSync,
|
|
286
|
+
): string[] {
|
|
287
|
+
const found: string[] = [];
|
|
288
|
+
let dir = dirname(runnerPath);
|
|
289
|
+
for (;;) {
|
|
290
|
+
const candidate = join(dir, 'node_modules');
|
|
291
|
+
if (exists(candidate)) {
|
|
292
|
+
found.push(candidate);
|
|
293
|
+
found.push(...linkedPackageDirs(candidate));
|
|
294
|
+
}
|
|
295
|
+
const parent = dirname(dir);
|
|
296
|
+
if (parent === dir) return [...new Set(found)];
|
|
297
|
+
dir = parent;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Package directories inside `dir` that are reachable ONLY through a symlink.
|
|
303
|
+
*
|
|
304
|
+
* A workspace install — bun's, pnpm's — puts a link in `node_modules` pointing
|
|
305
|
+
* sideways at `packages/<x>`. Binding `node_modules` binds the link and not
|
|
306
|
+
* what it points at, and the shim then dies on
|
|
307
|
+
* `ENOENT reading ".../node_modules/@celilo/capabilities"`. That is the second
|
|
308
|
+
* of the two failures this walk exists to prevent, and like the first it was
|
|
309
|
+
* found by running the jail rather than by reading it.
|
|
310
|
+
*
|
|
311
|
+
* An npm install has real directories here, so this finds nothing and costs
|
|
312
|
+
* one `readdir`. That is why it is written as the general case rather than as
|
|
313
|
+
* a development special case: the two shapes are the same rule, and a
|
|
314
|
+
* dev-only branch here would be a jail nobody tests until production.
|
|
315
|
+
*/
|
|
316
|
+
function linkedPackageDirs(dir: string): string[] {
|
|
317
|
+
const linked: string[] = [];
|
|
318
|
+
let entries: Dirent[];
|
|
319
|
+
try {
|
|
320
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
321
|
+
} catch {
|
|
322
|
+
return linked;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
for (const entry of entries) {
|
|
326
|
+
// `.bin` holds links to executables, not to packages. Following them binds
|
|
327
|
+
// single files and buys nothing.
|
|
328
|
+
if (entry.name === '.bin') continue;
|
|
329
|
+
const path = join(dir, entry.name);
|
|
330
|
+
// A scope is a real directory whose MEMBERS are the links.
|
|
331
|
+
if (entry.isDirectory() && entry.name.startsWith('@')) {
|
|
332
|
+
linked.push(...linkedPackageDirs(path));
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
if (!entry.isSymbolicLink()) continue;
|
|
336
|
+
try {
|
|
337
|
+
const target = realpathSync(path);
|
|
338
|
+
if (statSync(target).isDirectory()) linked.push(target);
|
|
339
|
+
} catch {
|
|
340
|
+
// A dangling link resolves to nothing and binds nothing.
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
return linked;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function realpathOrSelf(path: string): string {
|
|
347
|
+
try {
|
|
348
|
+
return realpathSync(path);
|
|
349
|
+
} catch {
|
|
350
|
+
return path;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Decide the command celilo spawns.
|
|
356
|
+
*
|
|
357
|
+
* Planning function (Rule 10.4) — pure, so every branch below is testable
|
|
358
|
+
* without a jail, which matters because the host running the tests usually has
|
|
359
|
+
* no jail at all.
|
|
360
|
+
*
|
|
361
|
+
* @param cmd - The unjailed command: the runtime and the runner shim.
|
|
362
|
+
* @param set - The derived mount set, or `undefined` when the caller has no
|
|
363
|
+
* module tree to jail (the executor's own fixtures, and the bus handler path).
|
|
364
|
+
*/
|
|
365
|
+
export function planJailedSpawn(
|
|
366
|
+
cmd: readonly string[],
|
|
367
|
+
set: MountSet | undefined,
|
|
368
|
+
availability: JailAvailability,
|
|
369
|
+
policy: JailPolicy,
|
|
370
|
+
exists: (path: string) => boolean = existsSync,
|
|
371
|
+
): JailPlan {
|
|
372
|
+
if (policy === 'off') {
|
|
373
|
+
return {
|
|
374
|
+
cmd,
|
|
375
|
+
mode: 'unjailed',
|
|
376
|
+
backend: availability.backend,
|
|
377
|
+
reason: 'CELILO_HOOK_JAIL=off: the operator switched the hook jail off on this host.',
|
|
378
|
+
skipped: [],
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
if (availability.backend === 'none' || !set) {
|
|
383
|
+
const reason = set
|
|
384
|
+
? availability.reason
|
|
385
|
+
: 'This invocation has no module tree to jail, so there is no mount set to enforce.';
|
|
386
|
+
if (policy === 'required') {
|
|
387
|
+
throw new Error(
|
|
388
|
+
`CELILO_HOOK_JAIL=required and no hook jail is available. ${reason ?? ''}`.trim(),
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
return { cmd, mode: 'unjailed', backend: availability.backend, reason, skipped: [] };
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// A tmpfs needs no source — bubblewrap creates it — so it is never dropped.
|
|
395
|
+
const present = set.entries.filter((e) => e.mode === 'tmpfs' || exists(e.path));
|
|
396
|
+
const skipped = set.entries.filter((e) => !present.includes(e)).map((e) => e.path);
|
|
397
|
+
|
|
398
|
+
return {
|
|
399
|
+
cmd: [
|
|
400
|
+
BWRAP,
|
|
401
|
+
...JAIL_NAMESPACE_ARGS,
|
|
402
|
+
...toBwrapArgs({ ...set, entries: present }),
|
|
403
|
+
'--',
|
|
404
|
+
...cmd,
|
|
405
|
+
],
|
|
406
|
+
mode: 'jailed',
|
|
407
|
+
backend: availability.backend,
|
|
408
|
+
skipped,
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
export interface JailModeRecord {
|
|
413
|
+
readonly mode: JailMode;
|
|
414
|
+
readonly backend: JailBackend;
|
|
415
|
+
readonly reason?: string;
|
|
416
|
+
/**
|
|
417
|
+
* The host this was measured on.
|
|
418
|
+
*
|
|
419
|
+
* D8's third state — "unjailed, was jailed yesterday" — is the only one that
|
|
420
|
+
* raises an alert, and it is defined per host. Without this the same file
|
|
421
|
+
* copied between boxes, or a database restored onto a new one, reads as a
|
|
422
|
+
* transition that never happened.
|
|
423
|
+
*/
|
|
424
|
+
readonly host: string;
|
|
425
|
+
readonly recordedAt: string;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Where the mode is written. `CELILO_HOOK_JAIL_MODE_PATH` overrides it, the
|
|
430
|
+
* same way `subscriber-store.ts` takes an override for the same reason.
|
|
431
|
+
*/
|
|
432
|
+
export function jailModeStorePath(): string {
|
|
433
|
+
return process.env.CELILO_HOOK_JAIL_MODE_PATH ?? join(getDataDir(), JAIL_MODE_FILE);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/** The last recorded mode, or `undefined` if nothing has recorded one yet. */
|
|
437
|
+
export function readJailMode(): JailModeRecord | undefined {
|
|
438
|
+
const path = jailModeStorePath();
|
|
439
|
+
if (!existsSync(path)) return undefined;
|
|
440
|
+
try {
|
|
441
|
+
return JSON.parse(readFileSync(path, 'utf-8')) as JailModeRecord;
|
|
442
|
+
} catch {
|
|
443
|
+
// A corrupt file is the same as no file for every consumer: the next run
|
|
444
|
+
// overwrites it. Throwing here would fail a hook over a state write.
|
|
445
|
+
return undefined;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Write down which mode this run used, and return what it replaced.
|
|
451
|
+
*
|
|
452
|
+
* The previous record is returned rather than acted on. A jailed-to-unjailed
|
|
453
|
+
* transition on the same host is what raises the self-monitor alert, and that
|
|
454
|
+
* monitor is task 4.4 — this is the state it will read.
|
|
455
|
+
*
|
|
456
|
+
* Best effort by design: a read-only data directory must not fail a hook.
|
|
457
|
+
*/
|
|
458
|
+
export function recordJailMode(plan: JailPlan): { previous?: JailModeRecord } {
|
|
459
|
+
const previous = readJailMode();
|
|
460
|
+
const record: JailModeRecord = {
|
|
461
|
+
mode: plan.mode,
|
|
462
|
+
backend: plan.backend,
|
|
463
|
+
...(plan.reason ? { reason: plan.reason } : {}),
|
|
464
|
+
host: hostname(),
|
|
465
|
+
recordedAt: new Date().toISOString(),
|
|
466
|
+
};
|
|
467
|
+
|
|
468
|
+
if (
|
|
469
|
+
previous &&
|
|
470
|
+
previous.host === record.host &&
|
|
471
|
+
previous.mode === record.mode &&
|
|
472
|
+
previous.backend === record.backend
|
|
473
|
+
) {
|
|
474
|
+
// Unchanged. Rewriting it every hook would churn the file and lose the
|
|
475
|
+
// one timestamp worth having: when the mode last CHANGED.
|
|
476
|
+
return { previous };
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
const path = jailModeStorePath();
|
|
480
|
+
try {
|
|
481
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
482
|
+
const tmp = `${path}.tmp`;
|
|
483
|
+
writeFileSync(tmp, `${JSON.stringify(record, null, 2)}\n`);
|
|
484
|
+
renameSync(tmp, path);
|
|
485
|
+
} catch {
|
|
486
|
+
// Deliberately swallowed, and the only place in this file that is. The
|
|
487
|
+
// mode is diagnostic state; a hook must not fail because celilo could not
|
|
488
|
+
// write it down.
|
|
489
|
+
}
|
|
490
|
+
return { previous };
|
|
491
|
+
}
|
package/src/hooks/mount-set.ts
CHANGED
|
@@ -84,6 +84,25 @@ export interface MountSetRequest {
|
|
|
84
84
|
readonly runtimePath: string;
|
|
85
85
|
/** The runner shim, which lives in celilo's tree rather than the module's. */
|
|
86
86
|
readonly runnerPath: string;
|
|
87
|
+
/**
|
|
88
|
+
* The `node_modules` directories the shim resolves its OWN imports through,
|
|
89
|
+
* nearest first. Empty means the shim is self-contained, which it is not.
|
|
90
|
+
*
|
|
91
|
+
* This row exists because the jail was run rather than read. `bwrap` built
|
|
92
|
+
* the namespace correctly, applied every mount below, and the shim then died
|
|
93
|
+
* on `Cannot find module '@celilo/capabilities'` — it imports `isCompiledHook`
|
|
94
|
+
* from there and Zod through `hook-protocol.ts`, and both resolve ABOVE
|
|
95
|
+
* `dirname(runnerPath)`. Under npm that is `/var/celilo/node_modules`; in the
|
|
96
|
+
* repo it is the workspace root's. Neither is inside the shim's directory,
|
|
97
|
+
* so without this the jail cannot start a hook at all, anywhere.
|
|
98
|
+
*
|
|
99
|
+
* The caller walks the filesystem for these (`runtimeModulePathsFor`), which
|
|
100
|
+
* is why they arrive as an argument rather than being computed here. Only
|
|
101
|
+
* directories literally NAMED `node_modules` are ever collected, and that is
|
|
102
|
+
* what keeps `/var/celilo` — `master.key`, `celilo.db` — out of the jail
|
|
103
|
+
* while `/var/celilo/node_modules` goes into it.
|
|
104
|
+
*/
|
|
105
|
+
readonly runtimeModulePaths?: readonly string[];
|
|
87
106
|
/** Contract-declared path inputs, already resolved to values. */
|
|
88
107
|
readonly pathInputs: readonly DeclaredPathInput[];
|
|
89
108
|
/**
|
|
@@ -154,6 +173,11 @@ export function deriveMountSet(request: MountSetRequest): MountSet {
|
|
|
154
173
|
// 2. The runtime. Without it nothing runs, so it is not really a policy row.
|
|
155
174
|
entries.push(entry(request.runtimePath, 'ro', 'the interpreter'));
|
|
156
175
|
entries.push(entry(dirname(request.runnerPath), 'ro', 'the runner shim celilo spawns'));
|
|
176
|
+
// See MountSetRequest.runtimeModulePaths. Without these the shim starts and
|
|
177
|
+
// immediately dies on `Cannot find module`.
|
|
178
|
+
for (const dir of request.runtimeModulePaths ?? []) {
|
|
179
|
+
entries.push(entry(resolve(dir), 'ro', "celilo's own dependencies, which the shim imports"));
|
|
180
|
+
}
|
|
157
181
|
for (const dir of RUNTIME_SUPPORT_DIRS) {
|
|
158
182
|
entries.push(entry(dir, 'ro', 'shared libraries and trust store'));
|
|
159
183
|
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test fixture: reports what a hook can and cannot reach from inside the jail.
|
|
3
|
+
*
|
|
4
|
+
* Every probe reports an OUTCOME rather than throwing, because the assertion
|
|
5
|
+
* that matters is unreachability and not any particular errno (task 4.9).
|
|
6
|
+
* bubblewrap removes the path and gives `ENOENT`; `sandbox-exec` denies it and
|
|
7
|
+
* gives `EPERM`. Both satisfy the requirement, so this fixture records only
|
|
8
|
+
* whether the access worked and hands the message back for the post-mortem.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
12
|
+
import { defineHook } from '@celilo/capabilities';
|
|
13
|
+
|
|
14
|
+
function probe(fn: () => void): { succeeded: boolean; detail: string } {
|
|
15
|
+
try {
|
|
16
|
+
fn();
|
|
17
|
+
return { succeeded: true, detail: 'ok' };
|
|
18
|
+
} catch (error) {
|
|
19
|
+
return { succeeded: false, detail: error instanceof Error ? error.message : String(error) };
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export default defineHook({
|
|
24
|
+
hook: 'container_created',
|
|
25
|
+
requires: [],
|
|
26
|
+
handler: async (ctx) => {
|
|
27
|
+
const config = ctx.config as {
|
|
28
|
+
planted_secret: string;
|
|
29
|
+
sibling_file: string;
|
|
30
|
+
staged_input: string;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
// Outside the module tree entirely, and the whole acceptance criterion:
|
|
35
|
+
// celilo's data directory is not bound, so the key is not merely denied
|
|
36
|
+
// but absent.
|
|
37
|
+
planted_secret: probe(() => {
|
|
38
|
+
readFileSync(config.planted_secret, 'utf-8');
|
|
39
|
+
}),
|
|
40
|
+
// A sibling module's tree. `<store>` itself is never bound, so one `..`
|
|
41
|
+
// reaches nothing.
|
|
42
|
+
sibling_write: probe(() => {
|
|
43
|
+
writeFileSync(config.sibling_file, 'trespassed');
|
|
44
|
+
}),
|
|
45
|
+
// The carve-out: `state/` sits INSIDE the read-only module tree and is
|
|
46
|
+
// bound read-write on top of it.
|
|
47
|
+
state_write: probe(() => {
|
|
48
|
+
writeFileSync(`${ctx.stateDir}/jail-probe`, 'state is writable');
|
|
49
|
+
}),
|
|
50
|
+
// A declared path input under os.tmpdir(), which D9 also makes a fresh
|
|
51
|
+
// tmpfs. If the tmpfs did not lead, this write lands in a private
|
|
52
|
+
// filesystem that vanishes when the hook exits — and the hook SUCCEEDS.
|
|
53
|
+
// The parent asserts the bytes survived, which is the only way to tell.
|
|
54
|
+
staged_write: probe(() => {
|
|
55
|
+
writeFileSync(`${config.staged_input}/produced`, 'staged input survived the tmpfs');
|
|
56
|
+
}),
|
|
57
|
+
};
|
|
58
|
+
},
|
|
59
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `icon` field's refinement (openspec/changes/module-icons, D3).
|
|
3
|
+
*
|
|
4
|
+
* This is the trust boundary: `manifest.yml` is hand-edited and the value ends
|
|
5
|
+
* up drawn in a coloured row, so the check that it is monochrome-capable lives
|
|
6
|
+
* here rather than in any consumer.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, expect, test } from 'bun:test';
|
|
10
|
+
import { ModuleManifestSchema } from './schema';
|
|
11
|
+
|
|
12
|
+
function manifestWith(icon?: string): Record<string, unknown> {
|
|
13
|
+
return {
|
|
14
|
+
celilo_contract: '1.0',
|
|
15
|
+
id: 'icon-fixture',
|
|
16
|
+
name: 'Icon Fixture',
|
|
17
|
+
version: '1.0.0',
|
|
18
|
+
...(icon === undefined ? {} : { icon }),
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe('manifest icon', () => {
|
|
23
|
+
test('accepts a BMP non-emoji scalar', () => {
|
|
24
|
+
const parsed = ModuleManifestSchema.parse(manifestWith('⛨'));
|
|
25
|
+
expect(parsed.icon).toBe('⛨');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('accepts absence', () => {
|
|
29
|
+
const parsed = ModuleManifestSchema.parse(manifestWith());
|
|
30
|
+
expect(parsed.icon).toBeUndefined();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('rejects U+1F512, naming the monochrome reason rather than the range', () => {
|
|
34
|
+
const result = ModuleManifestSchema.safeParse(manifestWith('🔒'));
|
|
35
|
+
expect(result.success).toBe(false);
|
|
36
|
+
if (result.success) throw new Error('expected the padlock to be rejected');
|
|
37
|
+
const message = result.error.issues[0]?.message ?? '';
|
|
38
|
+
expect(message).toContain('monochrome');
|
|
39
|
+
expect(message).toContain('U+1F512');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('rejects a two-character string', () => {
|
|
43
|
+
const result = ModuleManifestSchema.safeParse(manifestWith('⛨⛨'));
|
|
44
|
+
expect(result.success).toBe(false);
|
|
45
|
+
if (result.success) throw new Error('expected two characters to be rejected');
|
|
46
|
+
expect(result.error.issues[0]?.message ?? '').toContain('exactly one character');
|
|
47
|
+
});
|
|
48
|
+
});
|