@celilo/core 0.13.0 → 0.14.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celilo/core",
3
- "version": "0.13.0",
3
+ "version": "0.14.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",
@@ -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' },
@@ -1315,6 +1330,25 @@ export const COMMANDS: CommandDef[] = [
1315
1330
  ],
1316
1331
  flags: [{ name: 'clear', description: 'Clear the earmark', takesValue: false }],
1317
1332
  },
1333
+ {
1334
+ name: 'reclassify',
1335
+ description:
1336
+ "Re-classify a machine's stored interface zone labels against the current zone model",
1337
+ args: [
1338
+ {
1339
+ name: 'hostname',
1340
+ description: 'Machine hostname or IP',
1341
+ completion: 'machine_hostnames',
1342
+ },
1343
+ ],
1344
+ flags: [
1345
+ {
1346
+ name: 'apply',
1347
+ description: 'Write the new labels (default: dry run)',
1348
+ takesValue: false,
1349
+ },
1350
+ ],
1351
+ },
1318
1352
  ],
1319
1353
  },
1320
1354
  {
@@ -1469,6 +1503,25 @@ export const COMMANDS: CommandDef[] = [
1469
1503
  },
1470
1504
  ],
1471
1505
  },
1506
+ {
1507
+ name: 'reboot',
1508
+ description:
1509
+ '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',
1510
+ flags: [
1511
+ {
1512
+ name: 'allow-reboot',
1513
+ description:
1514
+ 'Approve the reboot without an interview question (the downtime takes the dispatcher, MCP server and remote API with it)',
1515
+ takesValue: false,
1516
+ },
1517
+ {
1518
+ name: 'verify',
1519
+ description:
1520
+ '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',
1521
+ takesValue: false,
1522
+ },
1523
+ ],
1524
+ },
1472
1525
  {
1473
1526
  name: 'migrate',
1474
1527
  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. Its token is `config`, which is not a
103
- // read verb, so the whole op is a write. Collapsing it would hand a
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 { ArgDef, CommandDef, FlagDef } from './command-registry';
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. The server's authz is 2-level (`command:subcommand`), so the
30
- * read/write split is too: a leaf is read-only iff its *op token* — the
31
- * subcommand (or, for a top-level leaf, the command) — is a read verb. This
32
- * guarantees a read tool's op is exactly what the RO principal is granted, and
33
- * never routes a mutating op onto the RO key. A mixed op like `module:config`
34
- * (get + set) has a non-read token (`config`) → classified write → RW.
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
- /** Token that decides read vs write: the subcommand, or the bare command. */
54
- function opToken(path: string[]): string {
55
- return path.length >= 2 ? path[1] : path[0];
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-only (→ RO principal)? See `READ_VERBS`. */
59
- export function isReadOnlyPath(path: string[]): boolean {
60
- return READ_VERBS.has(opToken(path));
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
- ...new Set(
91
- flattenLeaves(commands)
92
- .filter((l) => isReadOnlyPath(l.path))
93
- .map((l) => opOf(l.path)),
94
- ),
95
- ].sort();
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
+ });
@@ -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 the caller (needs the transport to reply) — never reached here.
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
- | { status: 'result'; exitCode: number }
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) return { status: 'result', exitCode: exit };
313
+ if (exit !== null) {
314
+ return { status: 'result', exitCode: exit, error: errors.join('\n') || undefined };
315
+ }
302
316
  }
303
317
  }
304
318
  return null;