@celilo/cli 1.13.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CELILO_CORE_MODULES.md +1 -1
- package/CELILO_SUBSYSTEMS.md +31 -5
- package/README.md +0 -2
- package/drizzle/0030_drop_module_builds_environment.sql +8 -0
- package/drizzle/meta/_journal.json +8 -1
- package/package.json +3 -3
- package/src/capabilities/public-web-helpers.test.ts +12 -6
- package/src/capabilities/public-web-publish.test.ts +42 -13
- package/src/capabilities/validation.test.ts +31 -0
- package/src/cli/commands/alerts-sweep.ts +3 -0
- package/src/cli/commands/console-get-chain.test.ts +96 -0
- package/src/cli/commands/console.ts +13 -5
- package/src/cli/commands/monitor.ts +15 -2
- package/src/cli/commands/notify-config.test.ts +79 -0
- package/src/cli/commands/notify-config.ts +13 -2
- package/src/cli/commands/system-doctor.test.ts +121 -1
- package/src/cli/commands/system-doctor.ts +151 -1
- package/src/cli/commands/system-ensure-fleet-key.ts +52 -0
- package/src/cli/completion.ts +10 -2
- package/src/cli/index.ts +7 -1
- package/src/console/closure.test.ts +76 -0
- package/src/console/closure.ts +87 -1
- package/src/console/control-plane-boundary.test.ts +82 -4
- package/src/console/projection.test.ts +63 -1
- package/src/console/projection.ts +39 -2
- package/src/db/schema.ts +0 -1
- package/src/hooks/capability-loader-control-plane-api.test.ts +124 -0
- package/src/hooks/capability-loader.ts +81 -10
- package/src/hooks/executor.ts +110 -17
- package/src/hooks/hook-jail-toolchain-reach.test.ts +224 -0
- package/src/hooks/hook-jail-unreachability.test.ts +28 -2
- package/src/hooks/hook-protocol.ts +44 -0
- package/src/hooks/hook-runner-entry.ts +23 -0
- package/src/hooks/hook-runner.ts +10 -0
- package/src/hooks/hook-trespass.test.ts +9 -3
- package/src/hooks/jail-browser-launch-flags.test.ts +34 -0
- package/src/hooks/jail.test.ts +92 -0
- package/src/hooks/jail.ts +128 -11
- package/src/hooks/mount-set.test.ts +28 -6
- package/src/hooks/mount-set.ts +34 -20
- package/src/hooks/remote-broker.test.ts +350 -0
- package/src/hooks/remote-broker.ts +404 -0
- package/src/hooks/run-named-hook.ts +2 -0
- package/src/hooks/test-fixtures/jail-probe-hook.ts +14 -1
- package/src/hooks/test-fixtures/jail-toolchain-hook.ts +227 -0
- package/src/hooks/test-fixtures/remote-bridge-probe.ts +82 -0
- package/src/hooks/unjailed-lint.test.ts +251 -0
- package/src/hooks/unjailed-lint.ts +395 -0
- package/src/manifest/contracts/v1.ts +22 -1
- package/src/manifest/validate.ts +25 -4
- package/src/module/web-root.ts +35 -0
- package/src/policy/module-business-baseline.ts +27 -3
- package/src/policy/module-script-scan.test.ts +22 -0
- package/src/policy/module-script-scan.ts +92 -1
- package/src/policy/no-hand-built-ssh.test.ts +39 -1
- package/src/policy/no-module-business-in-core.test.ts +1 -1
- package/src/services/alerting/hook-jail.test.ts +66 -0
- package/src/services/alerting/hook-jail.ts +70 -0
- package/src/services/alerting/run-monitor.test.ts +62 -0
- package/src/services/alerting/run-monitor.ts +12 -0
- package/src/services/alerting/sweep-runner.test.ts +1 -0
- package/src/services/api-principal-enrolment.test.ts +73 -0
- package/src/services/api-principal-enrolment.ts +55 -0
- package/src/services/backup-create.ts +36 -7
- package/src/services/backup-restore.ts +2 -0
- package/src/services/celilo-mgmt-hooks.test.ts +38 -79
- package/src/services/deploy-ansible.ts +9 -1
- package/src/services/fleet-key.test.ts +47 -0
- package/src/services/fleet-key.ts +75 -0
- package/src/services/health-runner.ts +2 -0
- package/src/services/module-build.test.ts +1 -64
- package/src/services/module-build.ts +10 -86
- package/src/services/module-deploy.ts +20 -0
- package/src/services/remote-access.test.ts +139 -0
- package/src/services/remote-access.ts +98 -0
- package/src/services/restore-from-file.ts +12 -6
- package/src/services/static-content-converge.test.ts +338 -0
- package/src/services/static-content-converge.ts +299 -0
- package/src/services/system-state-stage.test.ts +165 -0
- package/src/services/system-state-stage.ts +196 -0
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The unjailed advisory lint (hook-process-boundary task 4.7, design D8).
|
|
3
|
+
*
|
|
4
|
+
* On a host with no jail backend the hook runs with ambient filesystem
|
|
5
|
+
* access, so a hook that reads outside its tree works on that host and fails
|
|
6
|
+
* on the fleet — a divergence otherwise found in production one deploy at a
|
|
7
|
+
* time. This file closes the gap by making the runner shim check each
|
|
8
|
+
* filesystem access against the SAME mount set the jail would have enforced.
|
|
9
|
+
* It is the derivation's second consumer, which is the point: one computation
|
|
10
|
+
* and two consumers cannot drift the way a consumer and a hand-maintained
|
|
11
|
+
* list can.
|
|
12
|
+
*
|
|
13
|
+
* **This is a lint, not a security boundary, and neither the code nor its
|
|
14
|
+
* output may describe it as one.** It observes `node:fs` calls from inside
|
|
15
|
+
* the hook's own process, and module code bypasses it trivially — a `Bun.file`
|
|
16
|
+
* call, a `dlopen`, or a subprocess never crosses a wrapper. What it claims,
|
|
17
|
+
* and all it claims, is: on a jailed host, this access would have failed.
|
|
18
|
+
*
|
|
19
|
+
* **Channel.** The mount set arrives in the child's environment
|
|
20
|
+
* (`HOOK_MOUNT_SET_ENV`), set only when the run is unjailed — presence is the
|
|
21
|
+
* signal to install, so a jailed run carries nothing and installs nothing.
|
|
22
|
+
*
|
|
23
|
+
* **WHY THIS MODULE SELF-INSTALLS AT EVALUATION, AND WHY IT MUST STAY A
|
|
24
|
+
* LEAF.** Measured on Bun 1.3: the first ESM import of `node:fs` anywhere in
|
|
25
|
+
* a process resolves the builtin's named exports and never revisits them, so
|
|
26
|
+
* a wrapper planted on `require('node:fs')` AFTER that first import is
|
|
27
|
+
* invisible to every later `import { readFileSync } from 'node:fs'`. The
|
|
28
|
+
* shim's other imports (`jail.ts`, `@celilo/capabilities`) ESM-import
|
|
29
|
+
* `node:fs`, so the wrappers must be in place before any of them load. Hence
|
|
30
|
+
* `hook-runner-entry.ts`: the executor spawns IT, its first statement forces
|
|
31
|
+
* this module's evaluation and the install, and only then does the real
|
|
32
|
+
* runner load. Import order inside the runner is therefore irrelevant — but
|
|
33
|
+
* this module still imports nothing but `node:path`, `zod` (through
|
|
34
|
+
* `hook-protocol.ts`, which pulls no other builtins), and type-only imports,
|
|
35
|
+
* because the entry's guarantee is only as good as this file's graph is
|
|
36
|
+
* shallow. Importing `jail.ts` from here would silently disarm the lint; the
|
|
37
|
+
* integration tests in `unjailed-lint.test.ts` go red if that happens (they
|
|
38
|
+
* did, while this was being landed).
|
|
39
|
+
*
|
|
40
|
+
* The self-install is guarded on the runner shim's own environment variable,
|
|
41
|
+
* so a parent celilo process that imports this module for
|
|
42
|
+
* `mountSetEnvValue` never wraps its own filesystem.
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
import { basename, dirname, isAbsolute, resolve } from 'node:path';
|
|
46
|
+
import {
|
|
47
|
+
HOOK_MOUNT_SET_ENV,
|
|
48
|
+
HOOK_SOCKET_ENV,
|
|
49
|
+
type MountSetWire,
|
|
50
|
+
MountSetWireSchema,
|
|
51
|
+
} from './hook-protocol';
|
|
52
|
+
import type { MountSet } from './mount-set';
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The two paths the jail's namespace itself provides — bubblewrap's
|
|
56
|
+
* `--proc /proc` and `--dev /dev` (`jail.ts`'s JAIL_NAMESPACE_ARGS is built
|
|
57
|
+
* from this constant, so the two cannot drift) — rather than derivation
|
|
58
|
+
* rows. Counting them absent would be a false warning on every hook that
|
|
59
|
+
* touched either.
|
|
60
|
+
*/
|
|
61
|
+
export const JAIL_PROVIDED_PATHS = ['/proc', '/dev'] as const;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* `realpathSync` captured at module load, before the wrappers go in.
|
|
65
|
+
* `classifyAccess` runs from INSIDE a wrapped call (a wrapper reports, report
|
|
66
|
+
* classifies), so reaching for the module property here would re-enter the
|
|
67
|
+
* wrapper and classify forever.
|
|
68
|
+
*/
|
|
69
|
+
const PRISTINE_REALPATH = (require('node:fs') as typeof import('node:fs')).realpathSync;
|
|
70
|
+
|
|
71
|
+
/** The verdict for one access, against one mount set. */
|
|
72
|
+
export type AccessVerdict = 'allowed' | 'absent' | 'read-only';
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Distinct paths warned about before the lint stops listing and summarises.
|
|
76
|
+
* A hook walking a directory outside the set would otherwise emit one line
|
|
77
|
+
* per file; after this many distinct paths the reader has the point.
|
|
78
|
+
*/
|
|
79
|
+
const WARN_CAP = 25;
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Serialise a derived mount set for `HOOK_MOUNT_SET_ENV`.
|
|
83
|
+
*
|
|
84
|
+
* The executor calls this with the set it just derived; `parseLintMountSet`
|
|
85
|
+
* is the only thing on the reading side.
|
|
86
|
+
*/
|
|
87
|
+
export function mountSetEnvValue(set: MountSet): string {
|
|
88
|
+
return JSON.stringify(set);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Read and validate the mount set out of the child's environment.
|
|
93
|
+
*
|
|
94
|
+
* `undefined` when the variable is absent — the jailed case, and every
|
|
95
|
+
* invocation with no module tree. A value that fails validation is reported
|
|
96
|
+
* to stderr (the executor forwards it through the logger) and the run
|
|
97
|
+
* proceeds WITHOUT the lint rather than failing the hook over a diagnostic:
|
|
98
|
+
* the lint must never be the reason a deploy breaks. A silent skip would be
|
|
99
|
+
* the one thing worse than that, hence the stderr line.
|
|
100
|
+
*/
|
|
101
|
+
export function parseLintMountSet(value: string | undefined): MountSetWire | undefined {
|
|
102
|
+
if (value === undefined || value === '') return undefined;
|
|
103
|
+
let json: unknown;
|
|
104
|
+
try {
|
|
105
|
+
json = JSON.parse(value);
|
|
106
|
+
} catch (error) {
|
|
107
|
+
process.stderr.write(
|
|
108
|
+
`hook runner: ${HOOK_MOUNT_SET_ENV} is not valid JSON (${error instanceof Error ? error.message : String(error)}); the unjailed advisory lint is off this run.\n`,
|
|
109
|
+
);
|
|
110
|
+
return undefined;
|
|
111
|
+
}
|
|
112
|
+
const parsed = MountSetWireSchema.safeParse(json);
|
|
113
|
+
if (parsed.success) return parsed.data;
|
|
114
|
+
process.stderr.write(
|
|
115
|
+
`hook runner: ${HOOK_MOUNT_SET_ENV} failed validation; the unjailed advisory lint is off this run.\n`,
|
|
116
|
+
);
|
|
117
|
+
return undefined;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Warnings emitted before the hook's logger exists are buffered here and
|
|
122
|
+
* flushed by `forwardLintWarnings`. In practice there are none — the shim
|
|
123
|
+
* does no filesystem work between load and hook start — but the lint must
|
|
124
|
+
* never assume that.
|
|
125
|
+
*/
|
|
126
|
+
const buffered: string[] = [];
|
|
127
|
+
let emit: ((message: string) => void) | undefined;
|
|
128
|
+
let installed = false;
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Install the wrappers now, if this process is an unjailed hook runner.
|
|
132
|
+
*
|
|
133
|
+
* Idempotent, and called from TWO places by design: this module's own
|
|
134
|
+
* evaluation, and `hook-runner-entry.ts`'s first statement. Which one lands
|
|
135
|
+
* first depends on Bun's import evaluation order, which is not a thing to
|
|
136
|
+
* reason about twice — the guard makes both orders correct, and the entry
|
|
137
|
+
* makes one of them certain before any other module in the child process can
|
|
138
|
+
* ESM-load `node:fs` (see the docblock at the top). No-op everywhere else: no
|
|
139
|
+
* runner socket in the environment means this is not a hook runner process.
|
|
140
|
+
*/
|
|
141
|
+
export function installUnjailedLintIfUnjailed(): void {
|
|
142
|
+
if (installed) return;
|
|
143
|
+
if (process.env[HOOK_SOCKET_ENV] === undefined) return;
|
|
144
|
+
const set = parseLintMountSet(process.env[HOOK_MOUNT_SET_ENV]);
|
|
145
|
+
if (!set) return;
|
|
146
|
+
installed = true;
|
|
147
|
+
install(set, (message) => (emit ? emit(message) : buffered.push(message)));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Hand the lint's output to the hook's logger, draining anything buffered.
|
|
152
|
+
* Call once the shim's logger exists, before the hook script is imported.
|
|
153
|
+
*/
|
|
154
|
+
export function forwardLintWarnings(to: (message: string) => void): void {
|
|
155
|
+
emit = to;
|
|
156
|
+
for (const message of buffered.splice(0)) to(message);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Classify one access against the mount set.
|
|
161
|
+
*
|
|
162
|
+
* Last matching row wins, in `entries` order — that is bubblewrap's own rule
|
|
163
|
+
* (`--ro-bind` then a later `--bind` overrides), and the derivation emits its
|
|
164
|
+
* rows in exactly that order. A path under no row is absent, which is what
|
|
165
|
+
* the jail actually produces (mount-set.ts: not "denied", ABSENT).
|
|
166
|
+
*
|
|
167
|
+
* Relative paths resolve against `set.chdir`, because that is the working
|
|
168
|
+
* directory the jailed hook runs in (`--chdir`, task 4.2i) — the child's real
|
|
169
|
+
* cwd is celilo's, which is a directory the jail does not contain.
|
|
170
|
+
*/
|
|
171
|
+
export function classifyAccess(set: MountSetWire, path: string, write: boolean): AccessVerdict {
|
|
172
|
+
const absolute = isAbsolute(path) ? path : resolve(set.chdir, path);
|
|
173
|
+
const candidates = realpathCandidates(absolute);
|
|
174
|
+
let verdict: AccessVerdict = 'absent';
|
|
175
|
+
for (const candidate of candidates) {
|
|
176
|
+
verdict = strongest(verdict, classifyOne(set, candidate, write));
|
|
177
|
+
}
|
|
178
|
+
return verdict;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* The lexical path plus, when it can be resolved, its realpath.
|
|
183
|
+
*
|
|
184
|
+
* The mount set the lint compares against is the REALPATHED one
|
|
185
|
+
* (`realpathRequest`, task 4.2k — the jail binds real paths). A dev checkout
|
|
186
|
+
* on macOS reaches the hook through symlinked prefixes (`/tmp` →
|
|
187
|
+
* `/private/tmp`, `/var` → `/private/var`), so a hook writing the path celilo
|
|
188
|
+
* handed it and the same path as bound can disagree lexically. The lint
|
|
189
|
+
* accepts either form as "inside": the lint runs only where there is no jail
|
|
190
|
+
* to match byte-for-byte, and the alternative is a warning on every state
|
|
191
|
+
* write on a Mac.
|
|
192
|
+
*
|
|
193
|
+
* The realpath of the FULL path usually does not exist — the check fires
|
|
194
|
+
* before a create, and `realpathSync` fails on the file being created. So
|
|
195
|
+
* this resolves the longest existing ancestor and reattaches whatever is
|
|
196
|
+
* left: for `<state>/cursor`, where only `<state>` exists, the second
|
|
197
|
+
* candidate is the realpath of `<state>` plus `/cursor`.
|
|
198
|
+
*/
|
|
199
|
+
function realpathCandidates(path: string): string[] {
|
|
200
|
+
const candidates = [path];
|
|
201
|
+
let suffix = '';
|
|
202
|
+
let current = path;
|
|
203
|
+
for (;;) {
|
|
204
|
+
try {
|
|
205
|
+
const real = PRISTINE_REALPATH(current) + suffix;
|
|
206
|
+
if (!candidates.includes(real)) candidates.push(real);
|
|
207
|
+
return candidates;
|
|
208
|
+
} catch {
|
|
209
|
+
const parent = dirname(current);
|
|
210
|
+
if (parent === current) return candidates;
|
|
211
|
+
suffix = `/${basename(current)}${suffix}`;
|
|
212
|
+
current = parent;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function classifyOne(set: MountSetWire, absolute: string, write: boolean): AccessVerdict {
|
|
218
|
+
let verdict: AccessVerdict = 'absent';
|
|
219
|
+
for (const entry of set.entries) {
|
|
220
|
+
if (!covers(entry.path, absolute)) continue;
|
|
221
|
+
verdict = entry.mode === 'ro' && write ? 'read-only' : 'allowed';
|
|
222
|
+
}
|
|
223
|
+
for (const provided of JAIL_PROVIDED_PATHS) {
|
|
224
|
+
if (covers(provided, absolute)) verdict = 'allowed';
|
|
225
|
+
}
|
|
226
|
+
return verdict;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function covers(root: string, path: string): boolean {
|
|
230
|
+
const prefix = root.endsWith('/') ? root : `${root}/`;
|
|
231
|
+
return path === root || path.startsWith(prefix);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** The more informative of two verdicts, for the realpath candidate pair. */
|
|
235
|
+
function strongest(a: AccessVerdict, b: AccessVerdict): AccessVerdict {
|
|
236
|
+
if (a === 'allowed' || b === 'allowed') return 'allowed';
|
|
237
|
+
if (a === 'read-only' || b === 'read-only') return 'read-only';
|
|
238
|
+
return 'absent';
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Wrap every path-taking function this file knows about on both `node:fs`
|
|
243
|
+
* and `node:fs/promises`. The wrap fires BEFORE the underlying call, so it
|
|
244
|
+
* observes attempts — including attempts that succeed locally only because
|
|
245
|
+
* there is no jail, which is exactly the divergence it exists to surface.
|
|
246
|
+
*
|
|
247
|
+
* Warnings dedupe per (verdict, path) and are capped at WARN_CAP distinct
|
|
248
|
+
* paths, with one notice when the cap bites.
|
|
249
|
+
*/
|
|
250
|
+
function install(set: MountSetWire, warn: (message: string) => void): void {
|
|
251
|
+
const warned = new Set<string>();
|
|
252
|
+
let suppressed = 0;
|
|
253
|
+
|
|
254
|
+
const report = (rawPath: string, write: boolean): void => {
|
|
255
|
+
if (typeof rawPath !== 'string' || rawPath === '') return;
|
|
256
|
+
const absolute = isAbsolute(rawPath) ? rawPath : resolve(set.chdir, rawPath);
|
|
257
|
+
const verdict = classifyAccess(set, absolute, write);
|
|
258
|
+
if (verdict === 'allowed') return;
|
|
259
|
+
const key = `${verdict}:${absolute}`;
|
|
260
|
+
if (warned.has(key)) return;
|
|
261
|
+
if (warned.size >= WARN_CAP) {
|
|
262
|
+
suppressed += 1;
|
|
263
|
+
if (suppressed === 1) {
|
|
264
|
+
warn(
|
|
265
|
+
`Hook advisory: further path(s) outside the hook's mount set will be suppressed after ${WARN_CAP}.`,
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
warned.add(key);
|
|
271
|
+
warn(advisoryMessage(verdict, absolute));
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
for (const moduleId of ['node:fs', 'node:fs/promises'] as const) {
|
|
275
|
+
const mod = require(moduleId) as Record<string, unknown>;
|
|
276
|
+
for (const [name, pathArgsFor] of Object.entries(PATH_ARGS)) {
|
|
277
|
+
const original = mod[name];
|
|
278
|
+
if (typeof original !== 'function') continue;
|
|
279
|
+
mod[name] = function (this: unknown, ...call: unknown[]) {
|
|
280
|
+
const pathArgs = typeof pathArgsFor === 'function' ? pathArgsFor(call) : pathArgsFor;
|
|
281
|
+
for (const [index, write] of pathArgs) {
|
|
282
|
+
const value = call[index];
|
|
283
|
+
if (typeof value === 'string') report(value, write);
|
|
284
|
+
else if (value instanceof URL && value.protocol === 'file:') {
|
|
285
|
+
report(value.pathname, write);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
return (original as (...args: unknown[]) => unknown).apply(this, call);
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function advisoryMessage(verdict: AccessVerdict, path: string): string {
|
|
295
|
+
if (verdict === 'read-only') {
|
|
296
|
+
return [
|
|
297
|
+
`Hook advisory: '${path}' is read-only in the hook's mount set,`,
|
|
298
|
+
'so on a jailed host this write would fail.',
|
|
299
|
+
'Advisory lint, not a security boundary; module code bypasses it trivially.',
|
|
300
|
+
].join(' ');
|
|
301
|
+
}
|
|
302
|
+
return [
|
|
303
|
+
`Hook advisory: '${path}' is outside the hook's mount set,`,
|
|
304
|
+
'so on a jailed host this access would fail (the path is absent there, ENOENT).',
|
|
305
|
+
'Advisory lint, not a security boundary; module code bypasses it trivially.',
|
|
306
|
+
].join(' ');
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Which arguments of which functions carry a path, and whether the access is
|
|
311
|
+
* a write. Read and write variants share the table; a name missing from a
|
|
312
|
+
* given module is simply skipped, so one table covers `node:fs`, its `Sync`
|
|
313
|
+
* variants and `node:fs/promises`.
|
|
314
|
+
*
|
|
315
|
+
* Deliberately a table of the common calls rather than an exhaustive census:
|
|
316
|
+
* a function missed here is a warning not emitted, never a behaviour change.
|
|
317
|
+
* The lint is advisory and module code bypasses it trivially (design D8);
|
|
318
|
+
* exhaustive coverage would buy a boundary-shaped guarantee it cannot keep.
|
|
319
|
+
*/
|
|
320
|
+
const READ0: readonly (readonly [number, boolean])[] = [[0, false]];
|
|
321
|
+
const WRITE0: readonly (readonly [number, boolean])[] = [[0, true]];
|
|
322
|
+
const TWO_PATH: readonly (readonly [number, boolean])[] = [
|
|
323
|
+
[0, false],
|
|
324
|
+
[1, true],
|
|
325
|
+
];
|
|
326
|
+
|
|
327
|
+
const PATH_ARGS: Record<
|
|
328
|
+
string,
|
|
329
|
+
| readonly (readonly [number, boolean])[]
|
|
330
|
+
| ((call: unknown[]) => readonly (readonly [number, boolean])[])
|
|
331
|
+
> = {
|
|
332
|
+
// Reads.
|
|
333
|
+
readFile: READ0,
|
|
334
|
+
readFileSync: READ0,
|
|
335
|
+
readdir: READ0,
|
|
336
|
+
readdirSync: READ0,
|
|
337
|
+
stat: READ0,
|
|
338
|
+
statSync: READ0,
|
|
339
|
+
lstat: READ0,
|
|
340
|
+
lstatSync: READ0,
|
|
341
|
+
access: READ0,
|
|
342
|
+
accessSync: READ0,
|
|
343
|
+
existsSync: READ0,
|
|
344
|
+
realpath: READ0,
|
|
345
|
+
realpathSync: READ0,
|
|
346
|
+
readlink: READ0,
|
|
347
|
+
readlinkSync: READ0,
|
|
348
|
+
createReadStream: READ0,
|
|
349
|
+
opendir: READ0,
|
|
350
|
+
opendirSync: READ0,
|
|
351
|
+
// Writes.
|
|
352
|
+
writeFile: WRITE0,
|
|
353
|
+
writeFileSync: WRITE0,
|
|
354
|
+
appendFile: WRITE0,
|
|
355
|
+
appendFileSync: WRITE0,
|
|
356
|
+
mkdir: WRITE0,
|
|
357
|
+
mkdirSync: WRITE0,
|
|
358
|
+
mkdtemp: WRITE0,
|
|
359
|
+
mkdtempSync: WRITE0,
|
|
360
|
+
rm: WRITE0,
|
|
361
|
+
rmSync: WRITE0,
|
|
362
|
+
rmdir: WRITE0,
|
|
363
|
+
rmdirSync: WRITE0,
|
|
364
|
+
unlink: WRITE0,
|
|
365
|
+
unlinkSync: WRITE0,
|
|
366
|
+
chmod: WRITE0,
|
|
367
|
+
chmodSync: WRITE0,
|
|
368
|
+
chown: WRITE0,
|
|
369
|
+
chownSync: WRITE0,
|
|
370
|
+
truncate: WRITE0,
|
|
371
|
+
truncateSync: WRITE0,
|
|
372
|
+
utimes: WRITE0,
|
|
373
|
+
utimesSync: WRITE0,
|
|
374
|
+
createWriteStream: WRITE0,
|
|
375
|
+
symlink: [[1, true]],
|
|
376
|
+
// Both ends are paths.
|
|
377
|
+
rename: TWO_PATH,
|
|
378
|
+
renameSync: TWO_PATH,
|
|
379
|
+
copyFile: TWO_PATH,
|
|
380
|
+
copyFileSync: TWO_PATH,
|
|
381
|
+
cp: TWO_PATH,
|
|
382
|
+
cpSync: TWO_PATH,
|
|
383
|
+
// Flag-dependent: 'r' reads, everything else writes.
|
|
384
|
+
open: OPEN_FLAGS,
|
|
385
|
+
openSync: OPEN_FLAGS,
|
|
386
|
+
};
|
|
387
|
+
|
|
388
|
+
/** `open`'s flags argument decides read from write. */
|
|
389
|
+
function OPEN_FLAGS(call: unknown[]): readonly (readonly [number, boolean])[] {
|
|
390
|
+
const flags = call[1];
|
|
391
|
+
const flag = typeof flags === 'string' ? flags : 'r';
|
|
392
|
+
return [[0, flag !== 'r']];
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
installUnjailedLintIfUnjailed();
|
|
@@ -45,7 +45,8 @@ export interface ContractField {
|
|
|
45
45
|
* A field carrying a path-shaped value with no annotation here is a defect.
|
|
46
46
|
* `db_path` was one for months — passed by `backup-create.ts` and declared
|
|
47
47
|
* nowhere, so anything reasoning from this table was wrong about what a
|
|
48
|
-
* backup hook receives.
|
|
48
|
+
* backup hook receives. It is gone: design D9b replaced it with the staged
|
|
49
|
+
* `system_state_root` below, which is declared and annotated.
|
|
49
50
|
*/
|
|
50
51
|
path?: { access: PathAccess };
|
|
51
52
|
}
|
|
@@ -152,6 +153,26 @@ export const V1_HOOKS: ContractHooks = {
|
|
|
152
153
|
* terraform.tfstate.backup
|
|
153
154
|
*/
|
|
154
155
|
cross_module_root: { required: false, path: { access: 'read' } },
|
|
156
|
+
/**
|
|
157
|
+
* Path to a directory holding copies of celilo's OWN state: a
|
|
158
|
+
* WAL-correct `celilo.db` snapshot, `master.key`, the fleet `ssh/`
|
|
159
|
+
* keypair, and `module_src/<id>/` for every module's lean source.
|
|
160
|
+
* Populated by the framework under the same `cross_module_read`
|
|
161
|
+
* allow-list as `cross_module_root`; only celilo-mgmt receives it.
|
|
162
|
+
*
|
|
163
|
+
* This input is what makes celilo's own backup possible WITHOUT
|
|
164
|
+
* exempting celilo-mgmt from the hook jail (design D9b). The hook
|
|
165
|
+
* never read those bytes — it copied them into `backup_dir` — so the
|
|
166
|
+
* framework copies them into a directory it creates and hands over,
|
|
167
|
+
* and the data directory itself stays out of every mount set.
|
|
168
|
+
*
|
|
169
|
+
* It replaces `db_path`, which `backup-create.ts` passed for months
|
|
170
|
+
* while the contract declared nothing about it. That is the omission
|
|
171
|
+
* the docblock on `ContractField.path` names: anything reasoning from
|
|
172
|
+
* this table was wrong about what a backup hook receives, and a
|
|
173
|
+
* mount-set derivation walking the declared inputs could not see it.
|
|
174
|
+
*/
|
|
175
|
+
system_state_root: { required: false, path: { access: 'read' } },
|
|
155
176
|
},
|
|
156
177
|
outputs: {
|
|
157
178
|
artifact_count: { required: true },
|
package/src/manifest/validate.ts
CHANGED
|
@@ -202,14 +202,35 @@ const PRIVILEGED_CAPABILITY_ALLOW_LIST: Record<string, readonly string[]> = {
|
|
|
202
202
|
cross_module_read: ['celilo-mgmt'],
|
|
203
203
|
};
|
|
204
204
|
|
|
205
|
+
/**
|
|
206
|
+
* Framework-granted capabilities that any module may declare.
|
|
207
|
+
*
|
|
208
|
+
* Same satisfaction path as the allow-list above — celilo supplies them, so no
|
|
209
|
+
* module provides them and the resolver must not go looking for one — but a
|
|
210
|
+
* different authorization story, and the two were welded together while
|
|
211
|
+
* `cross_module_read` was the only entry.
|
|
212
|
+
*
|
|
213
|
+
* `cross_module_read` hands a module every OTHER module's terraform state, so
|
|
214
|
+
* who may hold it is a per-module trust decision and the list is the gate.
|
|
215
|
+
* `control_plane_api` mints a principal whose grants are derived from
|
|
216
|
+
* `readOnlyGrants(COMMANDS)` and cannot be widened by the caller, so the worst
|
|
217
|
+
* a wrongly-declared consumer obtains is celilo's own read verbs. The gate is
|
|
218
|
+
* the `requires` line, which a reviewer reads before the module is ever
|
|
219
|
+
* imported (web-ui-console D7b).
|
|
220
|
+
*
|
|
221
|
+
* An allow-list here was considered and rejected: it would put consumer module
|
|
222
|
+
* ids in core, so every new console-shaped consumer would need a core change
|
|
223
|
+
* and a `.deb` release before it could be imported at all.
|
|
224
|
+
*/
|
|
225
|
+
const FRAMEWORK_GRANTED_CAPABILITIES: ReadonlySet<string> = new Set(['control_plane_api']);
|
|
226
|
+
|
|
205
227
|
/**
|
|
206
228
|
* Whether `name` is a framework-granted privilege rather than a normal
|
|
207
|
-
* provider-backed capability. Privileges are satisfied by the framework
|
|
208
|
-
*
|
|
209
|
-
* must NOT expect a module to "provide" them.
|
|
229
|
+
* provider-backed capability. Privileges are satisfied by the framework, so
|
|
230
|
+
* the capability-provider resolver must NOT expect a module to "provide" them.
|
|
210
231
|
*/
|
|
211
232
|
export function isPrivilegedCapability(name: string): boolean {
|
|
212
|
-
return name in PRIVILEGED_CAPABILITY_ALLOW_LIST;
|
|
233
|
+
return name in PRIVILEGED_CAPABILITY_ALLOW_LIST || FRAMEWORK_GRANTED_CAPABILITIES.has(name);
|
|
213
234
|
}
|
|
214
235
|
|
|
215
236
|
/**
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a module's built web site lives on this host.
|
|
3
|
+
*
|
|
4
|
+
* ONE application of the `site/dist` convention. It is deliberately not applied
|
|
5
|
+
* inside the providers: `public_web` is framework-provided, but `private_web`
|
|
6
|
+
* and `external_web` are implemented by modules (`caddy-internal`,
|
|
7
|
+
* `generic-cpanel-hosting-provider`), and three copies of one convention is how
|
|
8
|
+
* the copies drift.
|
|
9
|
+
*
|
|
10
|
+
* Core is also the only party that CAN resolve it in the case that matters.
|
|
11
|
+
* When the `public_web` provider converges a rebuilt host, no consumer is
|
|
12
|
+
* running to be asked where its bytes are (design D10). So the path has to come
|
|
13
|
+
* from the module id, and `modules.source_path` is what core already holds.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { join } from 'node:path';
|
|
17
|
+
import { MODULE_WEB_ROOT } from '@celilo/capabilities';
|
|
18
|
+
import { eq } from 'drizzle-orm';
|
|
19
|
+
import type { DbClient } from '../db/client';
|
|
20
|
+
import { modules } from '../db/schema';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Absolute path to `<module root>/site/dist`, or undefined when no such module
|
|
24
|
+
* is installed.
|
|
25
|
+
*
|
|
26
|
+
* Does NOT check that the directory exists. Resolving a path and deciding
|
|
27
|
+
* whether it is usable are different jobs, and the second one belongs where the
|
|
28
|
+
* error can name the caller (Rule 10.1). `createPublicWeb`'s `requireWebRoot`
|
|
29
|
+
* does the existence check and says what to ship.
|
|
30
|
+
*/
|
|
31
|
+
export function resolveModuleWebRoot(moduleId: string, db: DbClient): string | undefined {
|
|
32
|
+
const module = db.select().from(modules).where(eq(modules.id, moduleId)).get();
|
|
33
|
+
if (!module) return undefined;
|
|
34
|
+
return join(module.sourcePath, MODULE_WEB_ROOT);
|
|
35
|
+
}
|
|
@@ -255,6 +255,18 @@ export const CAPABILITY_NAME_BASELINE: readonly CapabilityNameRow[] = [
|
|
|
255
255
|
count: 1,
|
|
256
256
|
why: "PERMANENT — validating a manifest's declared capability names against the registry is core's job",
|
|
257
257
|
},
|
|
258
|
+
{
|
|
259
|
+
file: 'apps/celilo/src/manifest/validate.ts',
|
|
260
|
+
capability: 'control_plane_api',
|
|
261
|
+
count: 1,
|
|
262
|
+
why: 'PERMANENT — framework-granted, so celilo satisfies it and no module provides it; the resolver has to be told that by name (web-ui-console D7b)',
|
|
263
|
+
},
|
|
264
|
+
{
|
|
265
|
+
file: 'apps/celilo/src/hooks/capability-loader.ts',
|
|
266
|
+
capability: 'control_plane_api',
|
|
267
|
+
count: 4,
|
|
268
|
+
why: "PERMANENT — framework-granted like web_routes above; core builds the method table because enrolment writes celilo's own api_principals row, which no module script can reach (web-ui-console D7b)",
|
|
269
|
+
},
|
|
258
270
|
{
|
|
259
271
|
file: 'apps/celilo/src/services/alerting/inbound-poller.ts',
|
|
260
272
|
capability: 'notification',
|
|
@@ -297,6 +309,12 @@ export const CAPABILITY_NAME_BASELINE: readonly CapabilityNameRow[] = [
|
|
|
297
309
|
count: 2,
|
|
298
310
|
why: "S16 — two more copies of S15's decision; fixing S15 removes all three (#938)",
|
|
299
311
|
},
|
|
312
|
+
{
|
|
313
|
+
file: 'apps/celilo/src/services/module-deploy.ts',
|
|
314
|
+
capability: 'public_web',
|
|
315
|
+
count: 1,
|
|
316
|
+
why: "D10 (capability-owned-tables stage 4): the public_web PROVIDER's own deploy writes the static-content release set into its generated inventory, so a rebuilt host recovers with no consumer involvement. The branch checks the deploying module's manifest `provides`, not a module name; the capability-loader callback is the other half of the same seam.",
|
|
317
|
+
},
|
|
300
318
|
{
|
|
301
319
|
file: 'apps/celilo/src/services/zone-policy.ts',
|
|
302
320
|
capability: 'public_web',
|
|
@@ -420,8 +438,8 @@ export const CAPABILITY_NAME_BASELINE: readonly CapabilityNameRow[] = [
|
|
|
420
438
|
{
|
|
421
439
|
file: 'packages/capabilities/src/firewall.ts',
|
|
422
440
|
capability: 'firewall',
|
|
423
|
-
count:
|
|
424
|
-
why: 'PERMANENT — X10, the firewall capability contract',
|
|
441
|
+
count: 3,
|
|
442
|
+
why: 'PERMANENT — X10, the firewall capability contract. The third is FIREWALL_CAPABILITY_NAME, exported so core queries the provider registry without spelling the literal; it moved a name OUT of apps/celilo/src/console/projection.ts rather than adding one',
|
|
425
443
|
},
|
|
426
444
|
{
|
|
427
445
|
file: 'packages/capabilities/src/public-web.ts',
|
|
@@ -476,7 +494,13 @@ export const PROVIDER_LITERAL_BASELINE: readonly ProviderLiteralRow[] = [
|
|
|
476
494
|
{
|
|
477
495
|
file: 'packages/capabilities/src/public-web.ts',
|
|
478
496
|
literal: '/srv/www',
|
|
479
|
-
count:
|
|
497
|
+
count: 3,
|
|
480
498
|
why: "X8 — the Caddyfile generator knows caddy's on-disk asset layout (#940)",
|
|
481
499
|
},
|
|
500
|
+
{
|
|
501
|
+
file: 'apps/celilo/src/services/static-content-converge.ts',
|
|
502
|
+
literal: '/srv/www',
|
|
503
|
+
count: 1,
|
|
504
|
+
why: "D10 (capability-owned-tables stage 4): the converge's slug-collision error names the on-disk release path an operator must fix. The role under modules/caddy owns the real path handling; this is message text, not path logic.",
|
|
505
|
+
},
|
|
482
506
|
];
|
|
@@ -38,6 +38,28 @@ describe('module script scan — SSH rules', () => {
|
|
|
38
38
|
});
|
|
39
39
|
});
|
|
40
40
|
|
|
41
|
+
describe('module script scan — namespace tools (hygiene, not a boundary)', () => {
|
|
42
|
+
const NS = 'namespace escape (bwrap/unshare/nsenter)';
|
|
43
|
+
|
|
44
|
+
// Each token asserted separately and by name. An alternation that silently
|
|
45
|
+
// loses one leg still passes a test that only ever exercises the first.
|
|
46
|
+
it.each([
|
|
47
|
+
['bwrap', 'run(`bwrap --dev-bind / / ${cmd}`);'],
|
|
48
|
+
['unshare', "run('unshare --user --map-root-user id');"],
|
|
49
|
+
['nsenter', "run('nsenter -t 1 -m -- ls /');"],
|
|
50
|
+
['CLONE_NEWUSER', 'const flags = CLONE_NEWUSER | CLONE_NEWNS;'],
|
|
51
|
+
])('catches %s', (_token, src) => {
|
|
52
|
+
expect(rules(src)).toContain(NS);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// The word boundaries are the whole of this rule's false-positive defence, so
|
|
56
|
+
// the negative case has to be a word that CONTAINS a token. `buildSharedConfig`
|
|
57
|
+
// would pass whether or not the `\b`s are there, which makes it no test at all.
|
|
58
|
+
it('does not fire on an identifier that merely contains a token', () => {
|
|
59
|
+
expect(rules('const unshared = pending.filter((p) => !p.shared);')).toEqual([]);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
41
63
|
describe('module script scan — raw-exec escape hatch', () => {
|
|
42
64
|
it('flags a runAppCommand call with no justification', () => {
|
|
43
65
|
expect(rules('const r = runAppCommand(system, "rm -f /tmp/x", run);')).toContain(
|