@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,404 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The remote-ops broker — stage 3's answering half (hook-process-boundary,
|
|
3
|
+
* design D12).
|
|
4
|
+
*
|
|
5
|
+
* A hook holds no SSH credential: the jail does not bind `~/.ssh`, so a
|
|
6
|
+
* hand-built `ssh` cannot authenticate anywhere. `@celilo/capabilities`'
|
|
7
|
+
* remote primitives detect the hook environment and send each operation here
|
|
8
|
+
* as a STRUCTURED request. This side holds the key, checks the target against
|
|
9
|
+
* the policy the caller injected, and runs the real primitive.
|
|
10
|
+
*
|
|
11
|
+
* Structured, never a shell string: a broker that accepted a command line
|
|
12
|
+
* from the jail would run arbitrary code as celilo OUTSIDE the jail, which is
|
|
13
|
+
* the exact thing the boundary exists to prevent. The same reasoning refuses
|
|
14
|
+
* `opts.env` (it never crosses; the asking half says so loudly) and confines
|
|
15
|
+
* the stream primitives' LOCAL paths to the directories this run already owns
|
|
16
|
+
* — without that check `streamBackup` would be a write-anything-as-celilo
|
|
17
|
+
* oracle aimed at `master.key`.
|
|
18
|
+
*
|
|
19
|
+
* **Attribution (D12).** Every operation on this channel belongs to the
|
|
20
|
+
* module whose hook is running — the policy is CONSTRUCTED for that module by
|
|
21
|
+
* the invoking service. A capability provider's internal transport (the
|
|
22
|
+
* `public_web` upload) does not pass through here at all: providers run in
|
|
23
|
+
* celilo's own process (design D10), and the one hand-built-ssh provider call
|
|
24
|
+
* site is being replaced by an Ansible converge under
|
|
25
|
+
* `openspec/changes/capability-owned-tables` (celilo#1014). The same
|
|
26
|
+
* `checkTarget` contract serves a provider-owned policy if provider transport
|
|
27
|
+
* is ever brokered — the owner is a construction argument, never inferred
|
|
28
|
+
* from a caller.
|
|
29
|
+
*
|
|
30
|
+
* Unlike the capability socket (one connection, one hook), this socket
|
|
31
|
+
* answers MANY short-lived connections: the asking half spawns a client per
|
|
32
|
+
* call, one request line in, one response line out.
|
|
33
|
+
*
|
|
34
|
+
* **A remote operation blocks celilo's event loop for its duration** — the
|
|
35
|
+
* primitives are execSync-backed, bounded by each call's own `timeoutMs`.
|
|
36
|
+
* That is not new exposure: before the process boundary every hook ran its
|
|
37
|
+
* ssh on this same loop, and provider transport (design D10) still does.
|
|
38
|
+
* The asking side is blocked in spawnSync anyway, so nothing concurrent is
|
|
39
|
+
* lost; timers (the hook's total/idle kill) fire late by at most one op.
|
|
40
|
+
*
|
|
41
|
+
* Execution function (Rule 10.1) — owns the socket and performs the calls.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
import { lstatSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
|
|
45
|
+
import { type Server, createServer } from 'node:net';
|
|
46
|
+
import { tmpdir } from 'node:os';
|
|
47
|
+
import { basename, dirname, join, resolve, sep } from 'node:path';
|
|
48
|
+
import {
|
|
49
|
+
type RemoteTarget,
|
|
50
|
+
type RunResult,
|
|
51
|
+
type Runner,
|
|
52
|
+
execRunner,
|
|
53
|
+
installAuthorizedKey,
|
|
54
|
+
remoteExec,
|
|
55
|
+
streamBackup,
|
|
56
|
+
streamRestore,
|
|
57
|
+
} from '@celilo/capabilities';
|
|
58
|
+
import { z } from 'zod';
|
|
59
|
+
import { createLineReader } from './hook-protocol';
|
|
60
|
+
import type { HookLogger } from './types';
|
|
61
|
+
|
|
62
|
+
/** The socket's filename beside the capability socket ('s') in the run dir. */
|
|
63
|
+
const REMOTE_SOCKET_NAME = 'r';
|
|
64
|
+
|
|
65
|
+
/** Refuse a request line larger than this rather than buffering it. */
|
|
66
|
+
const MAX_REQUEST_BYTES = 64 * 1024 * 1024;
|
|
67
|
+
|
|
68
|
+
const TargetSchema = z.object({
|
|
69
|
+
ipv4_address: z.string().min(1),
|
|
70
|
+
user: z.string().optional(),
|
|
71
|
+
port: z.number().int().optional(),
|
|
72
|
+
/**
|
|
73
|
+
* The module's OWN key, as content (task 5.4). The asking half reads the
|
|
74
|
+
* file inside the jail, so a hook can only send bytes it may already read
|
|
75
|
+
* — never a path for celilo to open on its behalf.
|
|
76
|
+
*/
|
|
77
|
+
identityContent: z.string().optional(),
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const OptsSchema = z
|
|
81
|
+
.object({
|
|
82
|
+
timeoutMs: z.number().optional(),
|
|
83
|
+
input: z.string().optional(),
|
|
84
|
+
})
|
|
85
|
+
.optional();
|
|
86
|
+
|
|
87
|
+
const RemoteRequestSchema = z.discriminatedUnion('op', [
|
|
88
|
+
z.object({
|
|
89
|
+
op: z.literal('remoteExec'),
|
|
90
|
+
target: TargetSchema,
|
|
91
|
+
command: z.string(),
|
|
92
|
+
opts: OptsSchema,
|
|
93
|
+
}),
|
|
94
|
+
z.object({
|
|
95
|
+
op: z.literal('streamBackup'),
|
|
96
|
+
target: TargetSchema,
|
|
97
|
+
producerCommand: z.string(),
|
|
98
|
+
localPath: z.string(),
|
|
99
|
+
opts: OptsSchema,
|
|
100
|
+
}),
|
|
101
|
+
z.object({
|
|
102
|
+
op: z.literal('streamRestore'),
|
|
103
|
+
target: TargetSchema,
|
|
104
|
+
localPath: z.string(),
|
|
105
|
+
consumerCommand: z.string(),
|
|
106
|
+
opts: OptsSchema,
|
|
107
|
+
}),
|
|
108
|
+
z.object({
|
|
109
|
+
op: z.literal('installAuthorizedKey'),
|
|
110
|
+
target: TargetSchema,
|
|
111
|
+
password: z.string(),
|
|
112
|
+
opts: OptsSchema,
|
|
113
|
+
}),
|
|
114
|
+
z.object({ op: z.literal('checkTarget'), target: TargetSchema }),
|
|
115
|
+
]);
|
|
116
|
+
|
|
117
|
+
type RemoteRequest = z.infer<typeof RemoteRequestSchema>;
|
|
118
|
+
type BridgeTarget = z.infer<typeof TargetSchema>;
|
|
119
|
+
|
|
120
|
+
export type RemoteCheckResult = { allowed: true } | { allowed: false; message: string };
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* D12's reachability policy, built by the invoking service for the module the
|
|
124
|
+
* run belongs to (`services/remote-access.ts`). The broker only enforces it.
|
|
125
|
+
*/
|
|
126
|
+
export interface RemoteAccessPolicy {
|
|
127
|
+
/** The module every operation on this channel is attributed to. */
|
|
128
|
+
moduleId: string;
|
|
129
|
+
/**
|
|
130
|
+
* May this module reach `target`? Called per operation, so it reads fresh
|
|
131
|
+
* state. `hasOwnCredential` is true when the request carries the module's
|
|
132
|
+
* own key or password rather than riding celilo's fleet key.
|
|
133
|
+
*/
|
|
134
|
+
checkTarget(
|
|
135
|
+
target: { ipv4_address: string; user?: string },
|
|
136
|
+
hasOwnCredential: boolean,
|
|
137
|
+
): RemoteCheckResult;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export interface RemoteBrokerOptions {
|
|
141
|
+
/** Directory holding the capability socket; the remote socket sits beside it. */
|
|
142
|
+
socketDir: string;
|
|
143
|
+
/**
|
|
144
|
+
* Absent means DENY: a run started without a policy refuses every
|
|
145
|
+
* operation, naming the gap. Fail closed, never open (Rule 6.4).
|
|
146
|
+
*/
|
|
147
|
+
policy?: RemoteAccessPolicy;
|
|
148
|
+
/** Local roots `streamRestore` may read from. Realpath'd here once. */
|
|
149
|
+
readableRoots: readonly string[];
|
|
150
|
+
/** Local roots `streamBackup` may write into. Realpath'd here once. */
|
|
151
|
+
writableRoots: readonly string[];
|
|
152
|
+
logger: HookLogger;
|
|
153
|
+
/** Every operation feeds the idle tracker — remote work is real work. */
|
|
154
|
+
onActivity: () => void;
|
|
155
|
+
/** Injectable transport for tests (default: the real execRunner). */
|
|
156
|
+
runner?: Runner;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export interface RemoteBroker {
|
|
160
|
+
/** Unix socket path, handed to the child as CELILO_HOOK_REMOTE_SOCKET. */
|
|
161
|
+
socketPath: string;
|
|
162
|
+
/** Refuse further operations. Idempotent. Called on kill and on cleanup. */
|
|
163
|
+
stop(): void;
|
|
164
|
+
/** Close the listener. The socket file goes with the run directory. */
|
|
165
|
+
close(): void;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Serve remote operations for one hook run. */
|
|
169
|
+
export async function startRemoteBroker(options: RemoteBrokerOptions): Promise<RemoteBroker> {
|
|
170
|
+
const socketPath = join(options.socketDir, REMOTE_SOCKET_NAME);
|
|
171
|
+
const runner = options.runner ?? execRunner;
|
|
172
|
+
const readableRoots = options.readableRoots.map(realpathOrSelf);
|
|
173
|
+
const writableRoots = options.writableRoots.map(realpathOrSelf);
|
|
174
|
+
let stopped = false;
|
|
175
|
+
|
|
176
|
+
const server: Server = createServer((socket) => {
|
|
177
|
+
socket.setEncoding('utf-8');
|
|
178
|
+
let answered = false;
|
|
179
|
+
let received = 0;
|
|
180
|
+
|
|
181
|
+
const answer = (response: object) => {
|
|
182
|
+
if (answered) return;
|
|
183
|
+
answered = true;
|
|
184
|
+
socket.end(`${JSON.stringify(response)}\n`);
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
const feed = createLineReader((line) => {
|
|
188
|
+
if (answered) return;
|
|
189
|
+
options.onActivity();
|
|
190
|
+
|
|
191
|
+
let parsed: RemoteRequest;
|
|
192
|
+
try {
|
|
193
|
+
parsed = RemoteRequestSchema.parse(JSON.parse(line));
|
|
194
|
+
} catch (error) {
|
|
195
|
+
answer(
|
|
196
|
+
failure(
|
|
197
|
+
`remote-ops broker: malformed request (${error instanceof Error ? error.message : String(error)})`,
|
|
198
|
+
),
|
|
199
|
+
);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (stopped) {
|
|
204
|
+
// Same rule as the capability broker: the kill is only real if the
|
|
205
|
+
// channel refuses afterwards (celilo#1003).
|
|
206
|
+
answer(refusal(parsed.op, 'Hook run has ended; refusing remote operation.'));
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
answer(dispatch(parsed));
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
socket.on('data', (chunk: string) => {
|
|
214
|
+
received += chunk.length;
|
|
215
|
+
if (received > MAX_REQUEST_BYTES) {
|
|
216
|
+
answer(failure('remote-ops broker: request too large'));
|
|
217
|
+
socket.destroy();
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
feed(chunk);
|
|
221
|
+
});
|
|
222
|
+
socket.on('error', () => {
|
|
223
|
+
// A client that died mid-request has nobody to answer.
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
function dispatch(request: RemoteRequest): object {
|
|
228
|
+
const hasOwnCredential =
|
|
229
|
+
request.op === 'installAuthorizedKey' || request.target.identityContent !== undefined;
|
|
230
|
+
|
|
231
|
+
const verdict: RemoteCheckResult = options.policy
|
|
232
|
+
? options.policy.checkTarget(
|
|
233
|
+
{ ipv4_address: request.target.ipv4_address, user: request.target.user },
|
|
234
|
+
hasOwnCredential,
|
|
235
|
+
)
|
|
236
|
+
: {
|
|
237
|
+
allowed: false,
|
|
238
|
+
message:
|
|
239
|
+
'This hook invocation was started without a remote-access policy, so remote operations are unavailable. This is a celilo defect — the invoking service must pass one.',
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
if (!verdict.allowed) {
|
|
243
|
+
options.logger.warn(`remote-ops refused: ${verdict.message}`);
|
|
244
|
+
return refusal(request.op, verdict.message);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
try {
|
|
248
|
+
return { ...perform(request) };
|
|
249
|
+
} catch (error) {
|
|
250
|
+
return failure(
|
|
251
|
+
`remote-ops broker: ${error instanceof Error ? error.message : String(error)}`,
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function perform(request: RemoteRequest): RunResult | RemoteCheckResult {
|
|
257
|
+
switch (request.op) {
|
|
258
|
+
case 'checkTarget':
|
|
259
|
+
return { allowed: true };
|
|
260
|
+
|
|
261
|
+
case 'remoteExec':
|
|
262
|
+
return withIdentity(request.target, (target) =>
|
|
263
|
+
remoteExec(target, request.command, request.opts ?? {}, runner),
|
|
264
|
+
);
|
|
265
|
+
|
|
266
|
+
case 'streamBackup': {
|
|
267
|
+
const local = containedPath(request.localPath, writableRoots, 'write');
|
|
268
|
+
if ('error' in local) return { ok: false, stdout: '', stderr: local.error };
|
|
269
|
+
return withIdentity(request.target, (target) =>
|
|
270
|
+
streamBackup(target, request.producerCommand, local.path, runner, request.opts ?? {}),
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
case 'streamRestore': {
|
|
275
|
+
const local = containedPath(request.localPath, readableRoots, 'read');
|
|
276
|
+
if ('error' in local) return { ok: false, stdout: '', stderr: local.error };
|
|
277
|
+
return withIdentity(request.target, (target) =>
|
|
278
|
+
streamRestore(target, local.path, request.consumerCommand, runner, request.opts ?? {}),
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
case 'installAuthorizedKey':
|
|
283
|
+
return withIdentity(request.target, (target) =>
|
|
284
|
+
installAuthorizedKey(target, request.password, runner, request.opts ?? {}),
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
await new Promise<void>((resolvePromise, reject) => {
|
|
290
|
+
server.once('error', reject);
|
|
291
|
+
server.listen(socketPath, resolvePromise);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
return {
|
|
295
|
+
socketPath,
|
|
296
|
+
stop: () => {
|
|
297
|
+
stopped = true;
|
|
298
|
+
},
|
|
299
|
+
close: () => {
|
|
300
|
+
stopped = true;
|
|
301
|
+
server.close();
|
|
302
|
+
},
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** A refusal in the shape the refused op's caller reads. */
|
|
307
|
+
function refusal(op: RemoteRequest['op'], message: string): object {
|
|
308
|
+
return op === 'checkTarget' ? { allowed: false, message } : failure(message);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function failure(message: string): { ok: false; stdout: ''; stderr: string } {
|
|
312
|
+
return { ok: false, stdout: '', stderr: message };
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Materialise the module's own key for the one call (task 5.4), then remove
|
|
317
|
+
* it. Content in, path out: the asking half read the bytes inside the jail,
|
|
318
|
+
* and the file below exists only for ssh's `-i`, which takes no stdin.
|
|
319
|
+
*/
|
|
320
|
+
function withIdentity(target: BridgeTarget, run: (target: RemoteTarget) => RunResult): RunResult {
|
|
321
|
+
const base: RemoteTarget = {
|
|
322
|
+
ipv4_address: target.ipv4_address,
|
|
323
|
+
...(target.user !== undefined ? { user: target.user } : {}),
|
|
324
|
+
...(target.port !== undefined ? { port: target.port } : {}),
|
|
325
|
+
};
|
|
326
|
+
if (target.identityContent === undefined) return run(base);
|
|
327
|
+
|
|
328
|
+
const dir = mkdtempSync(join(tmpdir(), 'celilo-hook-identity-'));
|
|
329
|
+
const keyPath = join(dir, 'key');
|
|
330
|
+
try {
|
|
331
|
+
writeFileSync(keyPath, target.identityContent, { mode: 0o600 });
|
|
332
|
+
return run({ ...base, identityFile: keyPath });
|
|
333
|
+
} finally {
|
|
334
|
+
rmSync(dir, { recursive: true, force: true });
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Confine a stream primitive's LOCAL path to this run's granted roots.
|
|
340
|
+
*
|
|
341
|
+
* Every component is resolved through the filesystem before the containment
|
|
342
|
+
* test. For a read, `realpath` of the whole path does that. For a write the
|
|
343
|
+
* leaf may not exist yet, so the PARENT is realpath'd — and a leaf that does
|
|
344
|
+
* exist as a symlink is refused outright: the shell's `>` follows it, so a
|
|
345
|
+
* link planted inside a writable directory (`state/evil` →
|
|
346
|
+
* `/var/celilo/master.key`, dangling inside the jail, resolving outside it)
|
|
347
|
+
* would otherwise aim celilo's write anywhere. Refusing the link beats
|
|
348
|
+
* resolving it, because the hook's own subprocesses could re-point it
|
|
349
|
+
* between a check and the write.
|
|
350
|
+
*
|
|
351
|
+
* The mount set binds the same path inside and outside the jail, which is
|
|
352
|
+
* what makes a contained path name the same file for both sides — and why a
|
|
353
|
+
* path OUTSIDE the roots (a bare `/tmp/x` under the run's private tmpfs)
|
|
354
|
+
* must be refused: celilo would write a file the hook then cannot even see.
|
|
355
|
+
*/
|
|
356
|
+
function containedPath(
|
|
357
|
+
localPath: string,
|
|
358
|
+
roots: readonly string[],
|
|
359
|
+
access: 'read' | 'write',
|
|
360
|
+
): { path: string } | { error: string } {
|
|
361
|
+
let real: string;
|
|
362
|
+
try {
|
|
363
|
+
if (access === 'read') {
|
|
364
|
+
real = realpathSync(resolve(localPath));
|
|
365
|
+
} else {
|
|
366
|
+
const resolved = resolve(localPath);
|
|
367
|
+
real = join(realpathSync(dirname(resolved)), basename(resolved));
|
|
368
|
+
// lstat, not existsSync: a dangling link (its target absent inside the
|
|
369
|
+
// jail) still IS a link, and that is the case that aims celilo's write
|
|
370
|
+
// out of the jail. existsSync follows the link and misses it.
|
|
371
|
+
if (isSymlink(real)) {
|
|
372
|
+
return {
|
|
373
|
+
error: `remote-ops broker: local path '${localPath}' is a symlink; a stream write follows links, so it must name the file itself.`,
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
} catch (error) {
|
|
378
|
+
return {
|
|
379
|
+
error: `remote-ops broker: local path '${localPath}' is not usable (${error instanceof Error ? error.message : String(error)})`,
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
if (roots.some((root) => real === root || real.startsWith(root + sep))) return { path: real };
|
|
384
|
+
return {
|
|
385
|
+
error: `remote-ops broker: local path '${localPath}' is outside this run's ${access === 'write' ? 'writable' : 'readable'} directories. Use ctx.stateDir or a declared path input (backup_dir, restore_dir).`,
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function realpathOrSelf(path: string): string {
|
|
390
|
+
try {
|
|
391
|
+
return realpathSync(resolve(path));
|
|
392
|
+
} catch {
|
|
393
|
+
return resolve(path);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** True if `path` itself is a symlink, dangling target included. */
|
|
398
|
+
function isSymlink(path: string): boolean {
|
|
399
|
+
try {
|
|
400
|
+
return lstatSync(path).isSymbolicLink();
|
|
401
|
+
} catch {
|
|
402
|
+
return false;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
listDnsRegistrations,
|
|
24
24
|
stampDnsRegistrationsRefreshed,
|
|
25
25
|
} from '../services/dns-registrations';
|
|
26
|
+
import { remoteAccessPolicy } from '../services/remote-access';
|
|
26
27
|
import { loadCapabilityFunctions } from './capability-loader';
|
|
27
28
|
import { invokeHook } from './executor';
|
|
28
29
|
import { loadHookConfigMap } from './load-hook-config';
|
|
@@ -190,6 +191,7 @@ export async function runNamedHook(
|
|
|
190
191
|
capabilities: capabilityFunctions,
|
|
191
192
|
requiredCapabilities,
|
|
192
193
|
systems: getModuleSystems(moduleId, db),
|
|
194
|
+
remoteAccess: remoteAccessPolicy(moduleId, db),
|
|
193
195
|
timeoutMs: options.timeoutMs,
|
|
194
196
|
},
|
|
195
197
|
);
|
|
@@ -8,7 +8,9 @@
|
|
|
8
8
|
* whether the access worked and hands the message back for the post-mortem.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import { readFileSync, writeFileSync } from 'node:fs';
|
|
11
|
+
import { readFileSync, readdirSync, writeFileSync } from 'node:fs';
|
|
12
|
+
import { homedir } from 'node:os';
|
|
13
|
+
import { join } from 'node:path';
|
|
12
14
|
import { defineHook } from '@celilo/capabilities';
|
|
13
15
|
|
|
14
16
|
function probe(fn: () => void): { succeeded: boolean; detail: string } {
|
|
@@ -54,6 +56,17 @@ export default defineHook({
|
|
|
54
56
|
staged_write: probe(() => {
|
|
55
57
|
writeFileSync(`${config.staged_input}/produced`, 'staged input survived the tmpfs');
|
|
56
58
|
}),
|
|
59
|
+
// Stage 3's observable (design D12, task 5.5): the SSH credential is
|
|
60
|
+
// not bound, so a hook cannot authenticate anywhere by hand. Reads
|
|
61
|
+
// `homedir()/.ssh` — the exact path the pre-stage-3 jail bound (via
|
|
62
|
+
// `join(homedir(), '.ssh')`), so the red baseline reaches a planted key
|
|
63
|
+
// and this branch does not. `homedir()` rather than `process.env.HOME`
|
|
64
|
+
// because that is what the mount decision used, and the two can differ.
|
|
65
|
+
ssh_key: probe(() => {
|
|
66
|
+
const sshDir = join(homedir(), '.ssh');
|
|
67
|
+
const names = readdirSync(sshDir);
|
|
68
|
+
for (const name of names) readFileSync(join(sshDir, name));
|
|
69
|
+
}),
|
|
57
70
|
};
|
|
58
71
|
},
|
|
59
72
|
});
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test fixture: what TOOLING can a hook reach from inside the jail (task 4.13)?
|
|
3
|
+
*
|
|
4
|
+
* `jail-probe-hook.ts` next door asks what a hook can reach on the FILESYSTEM,
|
|
5
|
+
* and D9's table is written in those terms. This one asks the question D9's
|
|
6
|
+
* table does not answer: the fleet's real hooks shell out. `remote.ts` builds an
|
|
7
|
+
* `ssh <user>@<host> <cmd>` string and hands it to `execSync`, which spawns
|
|
8
|
+
* `/bin/sh -c`. Neither `/bin/sh` nor `/usr/bin/ssh` appears anywhere in the
|
|
9
|
+
* derivation, and 22 module script files import `node:child_process`.
|
|
10
|
+
*
|
|
11
|
+
* So every probe here reports an outcome rather than throwing, the same way its
|
|
12
|
+
* neighbour does. The point is to MEASURE the reach rather than reason about it:
|
|
13
|
+
* reading the derivation is what produced the belief that stage 2 was fine, and
|
|
14
|
+
* reading it again would produce the same belief.
|
|
15
|
+
*
|
|
16
|
+
* `bwrap` is probed too, and its expected answer is the opposite of every other
|
|
17
|
+
* one here. It must NOT be reachable. D9 predicts exactly how that gets undone —
|
|
18
|
+
* a hook fails on a missing binary, somebody binds the directory it lives in,
|
|
19
|
+
* and the escape path reopens with a green suite — so the guard belongs in the
|
|
20
|
+
* same run as the failures that would tempt someone into it.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { execFileSync, execSync } from 'node:child_process';
|
|
24
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
25
|
+
import { dirname } from 'node:path';
|
|
26
|
+
import { BROWSER_ROOT, defineHook } from '@celilo/capabilities';
|
|
27
|
+
|
|
28
|
+
interface Probe {
|
|
29
|
+
succeeded: boolean;
|
|
30
|
+
detail: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function probe(fn: () => string): Probe {
|
|
34
|
+
try {
|
|
35
|
+
return { succeeded: true, detail: fn().slice(0, 200) };
|
|
36
|
+
} catch (error) {
|
|
37
|
+
return { succeeded: false, detail: error instanceof Error ? error.message : String(error) };
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function probeAsync(fn: () => Promise<string>): Promise<Probe> {
|
|
42
|
+
try {
|
|
43
|
+
return { succeeded: true, detail: (await fn()).slice(0, 200) };
|
|
44
|
+
} catch (error) {
|
|
45
|
+
return { succeeded: false, detail: error instanceof Error ? error.message : String(error) };
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Launch args for the two arms of task 4.10's controlled experiment, exported
|
|
51
|
+
* so the unit test that pins the flag on the launch path asserts the SAME
|
|
52
|
+
* definition the probes launch with rather than a copy of it.
|
|
53
|
+
*
|
|
54
|
+
* `SANDBOXED_LAUNCH_ARGS` is the experiment arm: Chromium's own sandbox on,
|
|
55
|
+
* which does not start inside the jail (its namespace sandbox cannot nest in
|
|
56
|
+
* the unprivileged user namespace bwrap creates). It must stay sandboxed or
|
|
57
|
+
* the comparison stops being one that isolates a variable.
|
|
58
|
+
*
|
|
59
|
+
* `JAILED_LAUNCH_ARGS` is the launch path itself, and the interim celilo#1215
|
|
60
|
+
* approved: a Chromium launched by a jailed hook starts with `--no-sandbox`,
|
|
61
|
+
* because the jail is then the only sandbox around the browser. The flag
|
|
62
|
+
* belongs here, in the launcher — never in the mount set or any other
|
|
63
|
+
* contract-shaped place. Brokered rendering outside the jail (v2, celilo#1215)
|
|
64
|
+
* is the preferred end state and removes the need for this flag.
|
|
65
|
+
*/
|
|
66
|
+
export const SANDBOXED_LAUNCH_ARGS: readonly string[] = ['--headless', '--dump-dom', 'about:blank'];
|
|
67
|
+
export const JAILED_LAUNCH_ARGS: readonly string[] = [
|
|
68
|
+
'--headless',
|
|
69
|
+
'--no-sandbox',
|
|
70
|
+
'--dump-dom',
|
|
71
|
+
'about:blank',
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
export default defineHook({
|
|
75
|
+
hook: 'container_created',
|
|
76
|
+
requires: [],
|
|
77
|
+
handler: async (ctx) => {
|
|
78
|
+
// Through `ctx.config`, not an env var. Stage 1 stopped forwarding the
|
|
79
|
+
// process environment into hooks (celilo#1158), so a probe keyed on
|
|
80
|
+
// `process.env` would read empty and report "no browser supplied" for a
|
|
81
|
+
// run that supplied one — a false ABSENT indistinguishable from a real one.
|
|
82
|
+
const browser = (ctx.config as { browser_executable?: string }).browser_executable ?? '';
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
// The one every other shell-out depends on. `execSync` spawns
|
|
86
|
+
// `/bin/sh -c`, so a jail without it cannot run ANY hook that uses
|
|
87
|
+
// `node:child_process`, whatever binary that hook was reaching for.
|
|
88
|
+
shell: probe(() => execSync('echo alive', { encoding: 'utf-8' })),
|
|
89
|
+
|
|
90
|
+
// Bypasses the shell to separate two failures that look identical
|
|
91
|
+
// through `execSync`: no `/bin/sh`, versus no target binary.
|
|
92
|
+
exec_without_shell: probe(() => execFileSync('/bin/echo', ['alive'], { encoding: 'utf-8' })),
|
|
93
|
+
|
|
94
|
+
// What `remote.ts` needs. Stage 2 binds `~/.ssh` read-only ON PURPOSE so
|
|
95
|
+
// that remote.ts keeps working, which makes the absence of the binary it
|
|
96
|
+
// feeds that key to the interesting result rather than a detail.
|
|
97
|
+
ssh: probe(() => execFileSync('/usr/bin/ssh', ['-V'], { encoding: 'utf-8', stdio: 'pipe' })),
|
|
98
|
+
|
|
99
|
+
// The deploy path. `ansible-playbook` is a Python program, so a bind of
|
|
100
|
+
// the binary alone would not be enough even if one existed.
|
|
101
|
+
ansible: probe(() =>
|
|
102
|
+
execFileSync('/usr/bin/ansible-playbook', ['--version'], { encoding: 'utf-8' }),
|
|
103
|
+
),
|
|
104
|
+
|
|
105
|
+
// Name resolution, in two halves, because they fail for different
|
|
106
|
+
// reasons and only one of them is about the mount set.
|
|
107
|
+
resolv_conf: probe(() => readFileSync('/etc/resolv.conf', 'utf-8')),
|
|
108
|
+
dns_lookup: await probeAsync(async () => {
|
|
109
|
+
// Bun's own resolver, so this measures name resolution rather than the
|
|
110
|
+
// absence of some binary that happens to do it. A jail with no
|
|
111
|
+
// `/etc/resolv.conf` has no nameserver to ask.
|
|
112
|
+
// Bounded, because the interesting failure is a HANG. With no
|
|
113
|
+
// `/etc/resolv.conf` there is no nameserver to ask and the resolver
|
|
114
|
+
// waits out its own retry schedule, so an unbounded probe reports the
|
|
115
|
+
// test runner's timeout instead of the jail's behaviour.
|
|
116
|
+
const answer = await Promise.race([
|
|
117
|
+
Bun.dns.lookup('example.com', { family: 4 }),
|
|
118
|
+
new Promise<never>((_, reject) =>
|
|
119
|
+
setTimeout(() => reject(new Error('no answer within 3s')), 3_000),
|
|
120
|
+
),
|
|
121
|
+
]);
|
|
122
|
+
return answer[0]?.address ?? 'resolved, no address';
|
|
123
|
+
}),
|
|
124
|
+
|
|
125
|
+
// Files a resolver reads before it ever touches the network.
|
|
126
|
+
nsswitch: probe(() => readFileSync('/etc/nsswitch.conf', 'utf-8')),
|
|
127
|
+
hosts_file: probe(() => readFileSync('/etc/hosts', 'utf-8')),
|
|
128
|
+
|
|
129
|
+
// ── Task 4.10, the browser case ──────────────────────────────────
|
|
130
|
+
//
|
|
131
|
+
// The task says to bind `~/.cache/ms-playwright`. That path is stale:
|
|
132
|
+
// `managed-browser-runtime` has since landed `BROWSER_ROOT` in
|
|
133
|
+
// `@celilo/capabilities`, so a provisioned browser lives under it and
|
|
134
|
+
// `resolveBrowser()` is what a hook asks for one. Both are probed, and
|
|
135
|
+
// only the playwright cache is ASSERTED — see the suite for why.
|
|
136
|
+
playwright_cache: probe(() => {
|
|
137
|
+
const home = process.env.HOME ?? '/root';
|
|
138
|
+
if (existsSync(`${home}/.cache/ms-playwright`)) return 'present';
|
|
139
|
+
throw new Error(`no ${home}/.cache/ms-playwright`);
|
|
140
|
+
}),
|
|
141
|
+
celilo_browser_root: probe(() => {
|
|
142
|
+
if (existsSync(BROWSER_ROOT)) return `present at ${BROWSER_ROOT}`;
|
|
143
|
+
throw new Error(`no ${BROWSER_ROOT} on this host`);
|
|
144
|
+
}),
|
|
145
|
+
|
|
146
|
+
// ⚠️ BROWSER_ROOT is a SUBDIRECTORY of celilo's Linux data directory
|
|
147
|
+
// (`/var/lib/celilo`), which also holds `celilo.db` and `master.key`.
|
|
148
|
+
// Binding it must not expose its siblings. bubblewrap creates the parent
|
|
149
|
+
// as an empty directory in the jail's namespace, so `..` reaches
|
|
150
|
+
// nothing — but that is the kind of claim worth a probe rather than a
|
|
151
|
+
// sentence, because it is the whole acceptance criterion D9 satisfies
|
|
152
|
+
// by absence.
|
|
153
|
+
data_dir_sibling: probe(() => {
|
|
154
|
+
const parent = dirname(BROWSER_ROOT);
|
|
155
|
+
return readFileSync(`${parent}/master.key`, 'utf-8');
|
|
156
|
+
}),
|
|
157
|
+
|
|
158
|
+
// Why a present binary can still fail to exec. `posix_spawn` reports a
|
|
159
|
+
// missing ELF interpreter as ENOENT on the PROGRAM, which is
|
|
160
|
+
// indistinguishable from the program being absent. Probed rather than
|
|
161
|
+
// inferred, and worth it: the loader turned out to be present and the
|
|
162
|
+
// first hypothesis was wrong.
|
|
163
|
+
dynamic_loader: probe(() => {
|
|
164
|
+
const found: string[] = [];
|
|
165
|
+
for (const dir of ['/lib', '/lib64', '/usr/lib']) {
|
|
166
|
+
try {
|
|
167
|
+
for (const name of readdirSync(dir)) {
|
|
168
|
+
if (name.startsWith('ld-linux') || name.startsWith('ld.so')) {
|
|
169
|
+
found.push(`${dir}/${name}`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
} catch {
|
|
173
|
+
// A directory the derivation legitimately skipped: `/lib64` does
|
|
174
|
+
// not exist on arm64 and `planJailedSpawn` drops a missing source.
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
if (found.length === 0) {
|
|
178
|
+
throw new Error('no ld-linux loader under /lib, /lib64 or /usr/lib');
|
|
179
|
+
}
|
|
180
|
+
return found.join(', ');
|
|
181
|
+
}),
|
|
182
|
+
|
|
183
|
+
// Did the bind happen at all? Separates "no browser reached the jail"
|
|
184
|
+
// from "one did and will not run", which the two launches below cannot
|
|
185
|
+
// tell apart on their own.
|
|
186
|
+
browser_binary_present: probe(() => {
|
|
187
|
+
if (!browser) throw new Error('no browser supplied to this run');
|
|
188
|
+
if (existsSync(browser)) return `present at ${browser}`;
|
|
189
|
+
throw new Error(`absent at ${browser}`);
|
|
190
|
+
}),
|
|
191
|
+
|
|
192
|
+
// The nesting question, and the only pair here needing a real browser.
|
|
193
|
+
//
|
|
194
|
+
// Two launches differing in ONE flag, so the result is a controlled
|
|
195
|
+
// experiment rather than an observation. Chromium's own sandbox forks a
|
|
196
|
+
// helper into a NEW user namespace, and nesting that inside
|
|
197
|
+
// bubblewrap's unprivileged one is what D9 and task 4.10 both predict
|
|
198
|
+
// will fail. `--no-sandbox` skips the fork, so the launch that must
|
|
199
|
+
// succeed carries it (celilo#1215 interim). The args come from the
|
|
200
|
+
// exported constants above, which the flag-pinning unit test shares.
|
|
201
|
+
browser_sandboxed: probe(() => {
|
|
202
|
+
if (!browser) throw new Error('no browser supplied to this run');
|
|
203
|
+
return execFileSync(browser, [...SANDBOXED_LAUNCH_ARGS], {
|
|
204
|
+
encoding: 'utf-8',
|
|
205
|
+
stdio: 'pipe',
|
|
206
|
+
timeout: 20_000,
|
|
207
|
+
});
|
|
208
|
+
}),
|
|
209
|
+
browser_no_sandbox: probe(() => {
|
|
210
|
+
if (!browser) throw new Error('no browser supplied to this run');
|
|
211
|
+
return execFileSync(browser, [...JAILED_LAUNCH_ARGS], {
|
|
212
|
+
encoding: 'utf-8',
|
|
213
|
+
stdio: 'pipe',
|
|
214
|
+
timeout: 20_000,
|
|
215
|
+
});
|
|
216
|
+
}),
|
|
217
|
+
|
|
218
|
+
// MUST be false. See the docblock.
|
|
219
|
+
bwrap_present: probe(() => {
|
|
220
|
+
for (const path of ['/usr/bin/bwrap', '/usr/local/bin/bwrap', '/bin/bwrap']) {
|
|
221
|
+
if (existsSync(path)) return `present at ${path}`;
|
|
222
|
+
}
|
|
223
|
+
throw new Error('no bwrap on any known path');
|
|
224
|
+
}),
|
|
225
|
+
};
|
|
226
|
+
},
|
|
227
|
+
});
|