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