@celilo/core 0.13.0 → 0.15.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/package.json +1 -1
- package/src/command-registry.ts +59 -2
- package/src/read-only-classifier.test.ts +28 -3
- package/src/read-only-classifier.ts +75 -20
- package/src/remote-client.test.ts +37 -0
- package/src/remote-client.ts +19 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@celilo/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"description": "Lightweight shared core for Celilo CLI tools — command registry, NDJSON API protocol, and remote SSH client. No Ink/React/drizzle/aws-sdk transitive deps, so an MCP server can reach the transport without installing the full CLI.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
package/src/command-registry.ts
CHANGED
|
@@ -98,7 +98,7 @@ export const COMMANDS: CommandDef[] = [
|
|
|
98
98
|
subcommands: [
|
|
99
99
|
{
|
|
100
100
|
name: 'grant',
|
|
101
|
-
description: 'Grant or replace API access for a principal',
|
|
101
|
+
description: 'Grant, amend, or replace API access for a principal',
|
|
102
102
|
args: [{ name: 'principal', description: 'Principal name (kebab-case)' }],
|
|
103
103
|
flags: [
|
|
104
104
|
{
|
|
@@ -109,9 +109,24 @@ export const COMMANDS: CommandDef[] = [
|
|
|
109
109
|
},
|
|
110
110
|
{
|
|
111
111
|
name: 'can',
|
|
112
|
-
description: 'Comma-separated grants (e.g. module:deploy,service:*)',
|
|
112
|
+
description: 'Comma-separated grants, REPLACING the set (e.g. module:deploy,service:*)',
|
|
113
113
|
takesValue: true,
|
|
114
114
|
},
|
|
115
|
+
{
|
|
116
|
+
name: 'add',
|
|
117
|
+
description: 'Comma-separated grants to add to the existing set',
|
|
118
|
+
takesValue: true,
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
name: 'remove',
|
|
122
|
+
description: 'Comma-separated grants to remove from the existing set',
|
|
123
|
+
takesValue: true,
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
name: 'force',
|
|
127
|
+
description: 'Allow a --can replacement that drops existing grants',
|
|
128
|
+
takesValue: false,
|
|
129
|
+
},
|
|
115
130
|
],
|
|
116
131
|
},
|
|
117
132
|
{ name: 'list', description: 'List API principals and their grants' },
|
|
@@ -135,6 +150,10 @@ export const COMMANDS: CommandDef[] = [
|
|
|
135
150
|
},
|
|
136
151
|
],
|
|
137
152
|
},
|
|
153
|
+
{
|
|
154
|
+
name: 'sweep',
|
|
155
|
+
description: 'Reap expired API sessions and prune retired records (hourly subscriber)',
|
|
156
|
+
},
|
|
138
157
|
],
|
|
139
158
|
},
|
|
140
159
|
{
|
|
@@ -1315,6 +1334,25 @@ export const COMMANDS: CommandDef[] = [
|
|
|
1315
1334
|
],
|
|
1316
1335
|
flags: [{ name: 'clear', description: 'Clear the earmark', takesValue: false }],
|
|
1317
1336
|
},
|
|
1337
|
+
{
|
|
1338
|
+
name: 'reclassify',
|
|
1339
|
+
description:
|
|
1340
|
+
"Re-classify a machine's stored interface zone labels against the current zone model",
|
|
1341
|
+
args: [
|
|
1342
|
+
{
|
|
1343
|
+
name: 'hostname',
|
|
1344
|
+
description: 'Machine hostname or IP',
|
|
1345
|
+
completion: 'machine_hostnames',
|
|
1346
|
+
},
|
|
1347
|
+
],
|
|
1348
|
+
flags: [
|
|
1349
|
+
{
|
|
1350
|
+
name: 'apply',
|
|
1351
|
+
description: 'Write the new labels (default: dry run)',
|
|
1352
|
+
takesValue: false,
|
|
1353
|
+
},
|
|
1354
|
+
],
|
|
1355
|
+
},
|
|
1318
1356
|
],
|
|
1319
1357
|
},
|
|
1320
1358
|
{
|
|
@@ -1469,6 +1507,25 @@ export const COMMANDS: CommandDef[] = [
|
|
|
1469
1507
|
},
|
|
1470
1508
|
],
|
|
1471
1509
|
},
|
|
1510
|
+
{
|
|
1511
|
+
name: 'reboot',
|
|
1512
|
+
description:
|
|
1513
|
+
'Reboot the management server. Refuses while a module operation is in flight, names it, and records the boot id so the return can be verified',
|
|
1514
|
+
flags: [
|
|
1515
|
+
{
|
|
1516
|
+
name: 'allow-reboot',
|
|
1517
|
+
description:
|
|
1518
|
+
'Approve the reboot without an interview question (the downtime takes the dispatcher, MCP server and remote API with it)',
|
|
1519
|
+
takesValue: false,
|
|
1520
|
+
},
|
|
1521
|
+
{
|
|
1522
|
+
name: 'verify',
|
|
1523
|
+
description:
|
|
1524
|
+
'Confirm a requested reboot came back: the boot id changed, the dispatcher is live and the CLI answers. Reports which is missing when it has not',
|
|
1525
|
+
takesValue: false,
|
|
1526
|
+
},
|
|
1527
|
+
],
|
|
1528
|
+
},
|
|
1472
1529
|
{
|
|
1473
1530
|
name: 'migrate',
|
|
1474
1531
|
description: 'Apply pending database migrations (idempotent; safe to re-run)',
|
|
@@ -99,10 +99,35 @@ describe('readOnlyGrants', () => {
|
|
|
99
99
|
});
|
|
100
100
|
|
|
101
101
|
test('a mixed-verb op is classified as a write', () => {
|
|
102
|
-
// `module config` covers get AND set.
|
|
103
|
-
//
|
|
104
|
-
// read-only principal the ability to set configuration.
|
|
102
|
+
// `module config` covers get AND set. One mutating leaf disqualifies the
|
|
103
|
+
// whole op, because a grant cannot be finer than the op. Collapsing it would
|
|
104
|
+
// hand a read-only principal the ability to set configuration.
|
|
105
105
|
expect(isReadOnlyPath(['module', 'config'])).toBe(false);
|
|
106
106
|
expect(readOnlyGrants(COMMANDS)).not.toContain('module:config');
|
|
107
107
|
});
|
|
108
108
|
});
|
|
109
|
+
|
|
110
|
+
describe('op-level classification (celilo#1404)', () => {
|
|
111
|
+
test('a sole-leaf read op nested 3 deep is a read, not a write', () => {
|
|
112
|
+
// `proxmox node list` is `proxmox:node`, whose only leaf is a read. Keying
|
|
113
|
+
// on the middle segment (`node`) called it a write, so the read-only
|
|
114
|
+
// principal was not granted it and the read tool that routes RO could not
|
|
115
|
+
// run — a silent exit 126.
|
|
116
|
+
expect(isReadOnlyPath(['proxmox', 'node', 'list'])).toBe(true);
|
|
117
|
+
expect(readOnlyGrants(COMMANDS)).toContain('proxmox:node');
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('one mutating leaf disqualifies its whole op', () => {
|
|
121
|
+
// `proxmox vm` covers list AND resize. The grant cannot be finer than the
|
|
122
|
+
// op, so the read `vm list` rides the RW key rather than widening RO.
|
|
123
|
+
expect(isReadOnlyPath(['proxmox', 'vm', 'list'])).toBe(false);
|
|
124
|
+
expect(readOnlyGrants(COMMANDS)).not.toContain('proxmox:vm');
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('module journal and module where are reachable', () => {
|
|
128
|
+
// The two the live server was missing.
|
|
129
|
+
const granted = new Set(readOnlyGrants(COMMANDS));
|
|
130
|
+
expect(granted).toContain('module:journal');
|
|
131
|
+
expect(granted).toContain('module:where');
|
|
132
|
+
});
|
|
133
|
+
});
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* automatically and a new write verb is not.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
import type
|
|
17
|
+
import { type ArgDef, COMMANDS, type CommandDef, type FlagDef } from './command-registry';
|
|
18
18
|
|
|
19
19
|
/** A runnable leaf command flattened from the registry tree. */
|
|
20
20
|
export interface Leaf {
|
|
@@ -26,18 +26,44 @@ export interface Leaf {
|
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
/**
|
|
29
|
-
* Read-only verbs
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
29
|
+
* Read-only LEAF verbs — the last path segment of a runnable command.
|
|
30
|
+
*
|
|
31
|
+
* Classification is two steps, because the server's authz is 2-level
|
|
32
|
+
* (`command:subcommand`) while the registry is up to 3 deep. A LEAF is a read
|
|
33
|
+
* iff its own verb is here; an OP is a read iff EVERY leaf under it is. That
|
|
34
|
+
* second step is what keeps a mixed op like `module config` (get + set) a write,
|
|
35
|
+
* and it is what stopped `proxmox node list` from being one: keying on the
|
|
36
|
+
* middle segment (`node`) classified a sole-leaf read op as a write, so the
|
|
37
|
+
* read-only principal could not run it (celilo#1404).
|
|
38
|
+
*
|
|
39
|
+
* DELIBERATELY NOT read verbs, though they look like ones:
|
|
40
|
+
* health, verify — run hooks ON the target host; reads in intent, side effects
|
|
41
|
+
* in mechanism.
|
|
42
|
+
* doctor, poll — poll writes alert state; doctor can repair.
|
|
43
|
+
* search — reaches the registry over the network.
|
|
44
|
+
* authorized-keys — renders every principal's full public key and forced-command
|
|
45
|
+
* line. `api list` (granted) shows key type and comment only.
|
|
35
46
|
*/
|
|
36
47
|
export const READ_VERBS: ReadonlySet<string> = new Set([
|
|
37
48
|
'list',
|
|
38
49
|
'status',
|
|
39
50
|
'where',
|
|
40
51
|
'get',
|
|
52
|
+
'show',
|
|
53
|
+
'show-config',
|
|
54
|
+
'show-zone',
|
|
55
|
+
'show-daemon',
|
|
56
|
+
'info',
|
|
57
|
+
'logs',
|
|
58
|
+
'tail',
|
|
59
|
+
'registrations',
|
|
60
|
+
'list-allocations',
|
|
61
|
+
'list-reservations',
|
|
62
|
+
'list-exclusions',
|
|
63
|
+
'list-subscribers',
|
|
64
|
+
'list-pending',
|
|
65
|
+
'list-failed',
|
|
66
|
+
'list-unanswered',
|
|
41
67
|
// Reads a deployed module's daemon journal. Read-only by construction —
|
|
42
68
|
// the only remote command it can emit is `journalctl` (services/module-journal.ts).
|
|
43
69
|
'journal',
|
|
@@ -50,14 +76,14 @@ export function opOf(path: string[]): string {
|
|
|
50
76
|
return path.length >= 2 ? `${path[0]}:${path[1]}` : path[0];
|
|
51
77
|
}
|
|
52
78
|
|
|
53
|
-
/**
|
|
54
|
-
function
|
|
55
|
-
return path.length
|
|
79
|
+
/** The leaf's own verb — the token that decides read vs write. */
|
|
80
|
+
function leafVerb(path: string[]): string {
|
|
81
|
+
return path[path.length - 1];
|
|
56
82
|
}
|
|
57
83
|
|
|
58
|
-
/** Is this leaf read
|
|
59
|
-
export function
|
|
60
|
-
return READ_VERBS.has(
|
|
84
|
+
/** Is this single leaf a read? (Not the same question as its OP — see below.) */
|
|
85
|
+
export function isReadOnlyLeaf(path: string[]): boolean {
|
|
86
|
+
return READ_VERBS.has(leafVerb(path));
|
|
61
87
|
}
|
|
62
88
|
|
|
63
89
|
/** Flatten the registry tree into its runnable leaves (nodes with no subcommands). */
|
|
@@ -82,15 +108,44 @@ export function flattenLeaves(commands: CommandDef[], prefix: string[] = []): Le
|
|
|
82
108
|
/**
|
|
83
109
|
* Every authz op a read-only principal needs, derived from a command registry.
|
|
84
110
|
*
|
|
111
|
+
* An op is granted iff EVERY leaf it can run is a read. One mutating leaf under
|
|
112
|
+
* an op disqualifies the whole op, because the grant cannot be finer than the op.
|
|
113
|
+
*
|
|
85
114
|
* Sorted and de-duplicated so the result is stable enough to compare in a test
|
|
86
115
|
* and to print in a grant line.
|
|
87
116
|
*/
|
|
88
117
|
export function readOnlyGrants(commands: CommandDef[]): string[] {
|
|
89
|
-
return [
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
118
|
+
return [...readOnlyOps(commands)].sort();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function readOnlyOps(commands: CommandDef[]): Set<string> {
|
|
122
|
+
const leavesByOp = new Map<string, string[][]>();
|
|
123
|
+
for (const leaf of flattenLeaves(commands)) {
|
|
124
|
+
const op = opOf(leaf.path);
|
|
125
|
+
const group = leavesByOp.get(op);
|
|
126
|
+
if (group) group.push(leaf.path);
|
|
127
|
+
else leavesByOp.set(op, [leaf.path]);
|
|
128
|
+
}
|
|
129
|
+
const ops = new Set<string>();
|
|
130
|
+
for (const [op, paths] of leavesByOp) {
|
|
131
|
+
if (paths.every(isReadOnlyLeaf)) ops.add(op);
|
|
132
|
+
}
|
|
133
|
+
return ops;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Every authz op the registry can run, read and write alike. Sorted, de-duped. */
|
|
137
|
+
export function allOps(commands: CommandDef[]): string[] {
|
|
138
|
+
return [...new Set(flattenLeaves(commands).map((l) => opOf(l.path)))].sort();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
let liveReadOnlyOps: Set<string> | null = null;
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Is this path routed to the read-only principal? Answered at OP level against
|
|
145
|
+
* the live registry, so a leaf's routing and the RO principal's grants can never
|
|
146
|
+
* disagree — a read tool routes RO iff RO is granted its op.
|
|
147
|
+
*/
|
|
148
|
+
export function isReadOnlyPath(path: string[]): boolean {
|
|
149
|
+
liveReadOnlyOps ??= readOnlyOps(COMMANDS);
|
|
150
|
+
return liveReadOnlyOps.has(opOf(path));
|
|
96
151
|
}
|
|
@@ -177,3 +177,40 @@ describe('stdin carrying JSON-RPC survives a raised interview', () => {
|
|
|
177
177
|
expect(out).toContain(`SURVIVED:${rpc}`);
|
|
178
178
|
}, 30_000);
|
|
179
179
|
});
|
|
180
|
+
|
|
181
|
+
describe("a denial's text survives to a capturing caller (celilo#1404)", () => {
|
|
182
|
+
test('the outcome carries the server error, which never reaches `out`', async () => {
|
|
183
|
+
// The server DOES say why: `permission denied: "<principal>" is not granted
|
|
184
|
+
// "<op>"`. But it says it as a protocol `error` message, not as command
|
|
185
|
+
// output, so a caller that captures `out` (the MCP server) saw exit 126 with
|
|
186
|
+
// an empty body and read it as the command failing on the target host.
|
|
187
|
+
const denial = 'permission denied: "celilo-mcp-ro" is not granted "module:journal"';
|
|
188
|
+
const transport = fakeTransport([
|
|
189
|
+
JSON.stringify({ type: 'error', error: denial }),
|
|
190
|
+
JSON.stringify({ type: 'result', success: false, exitCode: 126 }),
|
|
191
|
+
]);
|
|
192
|
+
const captured: string[] = [];
|
|
193
|
+
|
|
194
|
+
const outcome = await runRemoteClient('host', ['module', 'journal', 'caddy'], {
|
|
195
|
+
openTransport: () => transport,
|
|
196
|
+
out: { write: (s: string) => void captured.push(s), isTTY: false },
|
|
197
|
+
onBlocked: 'return',
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
expect(outcome).toEqual({ status: 'result', exitCode: 126, error: denial });
|
|
201
|
+
// Still not command output — a caller must not mistake it for one.
|
|
202
|
+
expect(captured.join('')).toBe('');
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test('a clean run carries no error', async () => {
|
|
206
|
+
const transport = fakeTransport([
|
|
207
|
+
JSON.stringify({ type: 'result', success: true, exitCode: 0 }),
|
|
208
|
+
]);
|
|
209
|
+
const outcome = await runRemoteClient('host', ['module', 'list'], {
|
|
210
|
+
openTransport: () => transport,
|
|
211
|
+
out: { write() {}, isTTY: false },
|
|
212
|
+
onBlocked: 'return',
|
|
213
|
+
});
|
|
214
|
+
expect(outcome).toEqual({ status: 'result', exitCode: 0 });
|
|
215
|
+
});
|
|
216
|
+
});
|
package/src/remote-client.ts
CHANGED
|
@@ -200,11 +200,9 @@ function applyMessage(
|
|
|
200
200
|
}
|
|
201
201
|
return null;
|
|
202
202
|
case 'error':
|
|
203
|
-
process.stderr.write(`${msg.error}\n`);
|
|
204
|
-
return null;
|
|
205
203
|
case 'interview':
|
|
206
204
|
case 'blocked':
|
|
207
|
-
// Handled by
|
|
205
|
+
// Handled by consumeStream (needs to retain the text / reply) — never here.
|
|
208
206
|
return null;
|
|
209
207
|
case 'result':
|
|
210
208
|
return msg.exitCode;
|
|
@@ -217,7 +215,14 @@ function applyMessage(
|
|
|
217
215
|
* is still alive under `sessionId`.
|
|
218
216
|
*/
|
|
219
217
|
export type RemoteOutcome =
|
|
220
|
-
|
|
218
|
+
/**
|
|
219
|
+
* `error` is the server's in-band `error` text, retained so a CAPTURING
|
|
220
|
+
* caller can report it. It is written to stderr as well, for a terminal one.
|
|
221
|
+
* Without it an authz denial reached the MCP as exit 126 with empty output —
|
|
222
|
+
* a silent denial the caller reads as the command failing on the host
|
|
223
|
+
* (celilo#1404).
|
|
224
|
+
*/
|
|
225
|
+
| { status: 'result'; exitCode: number; error?: string }
|
|
221
226
|
| { status: 'blocked'; sessionId: string; eventId: string; question: string; key?: string };
|
|
222
227
|
|
|
223
228
|
/** Exit code a caller reports when a command parked rather than finishing. */
|
|
@@ -242,6 +247,7 @@ async function consumeStream(
|
|
|
242
247
|
): Promise<RemoteOutcome | null> {
|
|
243
248
|
const { out, display, renderInterview } = opts;
|
|
244
249
|
const decoder = new TextDecoder();
|
|
250
|
+
const errors: string[] = [];
|
|
245
251
|
let buffer = '';
|
|
246
252
|
|
|
247
253
|
for await (const chunk of transport.stdout) {
|
|
@@ -281,6 +287,12 @@ async function consumeStream(
|
|
|
281
287
|
continue;
|
|
282
288
|
}
|
|
283
289
|
|
|
290
|
+
if (msg.type === 'error') {
|
|
291
|
+
errors.push(msg.error);
|
|
292
|
+
process.stderr.write(`${msg.error}\n`);
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
|
|
284
296
|
if (msg.type === 'blocked') {
|
|
285
297
|
if (opts.onBlocked === 'wait') {
|
|
286
298
|
out.write(
|
|
@@ -298,7 +310,9 @@ async function consumeStream(
|
|
|
298
310
|
}
|
|
299
311
|
|
|
300
312
|
const exit = applyMessage(msg, display, out);
|
|
301
|
-
if (exit !== null)
|
|
313
|
+
if (exit !== null) {
|
|
314
|
+
return { status: 'result', exitCode: exit, error: errors.join('\n') || undefined };
|
|
315
|
+
}
|
|
302
316
|
}
|
|
303
317
|
}
|
|
304
318
|
return null;
|