@celilo/core 0.9.1 → 0.10.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 +41 -1
- package/src/index.ts +2 -0
- package/src/read-only-classifier.test.ts +108 -0
- package/src/read-only-classifier.ts +96 -0
- package/src/ssh-transport.ts +111 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@celilo/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.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
|
@@ -1951,7 +1951,19 @@ export const COMMANDS: CommandDef[] = [
|
|
|
1951
1951
|
completion: 'module_ids',
|
|
1952
1952
|
},
|
|
1953
1953
|
],
|
|
1954
|
-
flags: [
|
|
1954
|
+
flags: [
|
|
1955
|
+
{ name: 'limit', description: 'Number of backups to show', takesValue: true },
|
|
1956
|
+
{
|
|
1957
|
+
name: 'json',
|
|
1958
|
+
description: 'Emit exact timestamps as JSON, for the console and other programs',
|
|
1959
|
+
takesValue: false,
|
|
1960
|
+
},
|
|
1961
|
+
{
|
|
1962
|
+
name: 'since',
|
|
1963
|
+
description: 'Only attempts from the last N days',
|
|
1964
|
+
takesValue: true,
|
|
1965
|
+
},
|
|
1966
|
+
],
|
|
1955
1967
|
},
|
|
1956
1968
|
{
|
|
1957
1969
|
name: 'restore',
|
|
@@ -2041,6 +2053,34 @@ export const COMMANDS: CommandDef[] = [
|
|
|
2041
2053
|
},
|
|
2042
2054
|
],
|
|
2043
2055
|
},
|
|
2056
|
+
{
|
|
2057
|
+
name: 'console',
|
|
2058
|
+
description: 'Narrow read-only projections for the web console',
|
|
2059
|
+
subcommands: [
|
|
2060
|
+
// The subcommand token decides read/write classification (READ_VERBS in
|
|
2061
|
+
// read-only-classifier.ts), and the console's API principal is granted
|
|
2062
|
+
// read ops only. These are named from that vocabulary rather than for
|
|
2063
|
+
// prose: `console roster` would read better and classify as a WRITE.
|
|
2064
|
+
{
|
|
2065
|
+
name: 'status',
|
|
2066
|
+
description: 'Fleet roster with observed health, systems and backup age (dashboard poll)',
|
|
2067
|
+
flags: [{ name: 'json', description: 'Emit JSON', takesValue: false }],
|
|
2068
|
+
},
|
|
2069
|
+
{
|
|
2070
|
+
name: 'get',
|
|
2071
|
+
description: "A module's bounded capability closure",
|
|
2072
|
+
args: [{ name: 'module-id', description: 'Module to resolve the closure for' }],
|
|
2073
|
+
flags: [
|
|
2074
|
+
{
|
|
2075
|
+
name: 'depth',
|
|
2076
|
+
description: 'Maximum hops to walk (default 2; 0 walks the whole graph)',
|
|
2077
|
+
takesValue: true,
|
|
2078
|
+
},
|
|
2079
|
+
{ name: 'json', description: 'Emit JSON', takesValue: false },
|
|
2080
|
+
],
|
|
2081
|
+
},
|
|
2082
|
+
],
|
|
2083
|
+
},
|
|
2044
2084
|
{
|
|
2045
2085
|
name: 'completion',
|
|
2046
2086
|
description: 'Generate shell completion scripts',
|
package/src/index.ts
CHANGED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The gate that keeps a read-only principal read-only.
|
|
3
|
+
*
|
|
4
|
+
* The MCP server's RO key and the web console's API principal are both granted
|
|
5
|
+
* exactly `readOnlyGrants(COMMANDS)`. If a write op ever appears in that set,
|
|
6
|
+
* both of them quietly gain the ability to change the fleet, and nothing else in
|
|
7
|
+
* either codebase would notice: the grant is minted server-side from this list,
|
|
8
|
+
* and the server would be doing precisely what it was told.
|
|
9
|
+
*
|
|
10
|
+
* So this asserts the negative directly against the live registry.
|
|
11
|
+
*/
|
|
12
|
+
import { describe, expect, test } from 'bun:test';
|
|
13
|
+
import { COMMANDS, type CommandDef } from './command-registry';
|
|
14
|
+
import { isReadOnlyPath, opOf, readOnlyGrants } from './read-only-classifier';
|
|
15
|
+
|
|
16
|
+
/** Every op the registry can run, read and write alike. */
|
|
17
|
+
function allOps(commands: CommandDef[], prefix: string[] = []): string[] {
|
|
18
|
+
return commands.flatMap((cmd) => {
|
|
19
|
+
const path = [...prefix, cmd.name];
|
|
20
|
+
return cmd.subcommands?.length ? allOps(cmd.subcommands, path) : [opOf(path)];
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Ops that are unambiguously mutating, written out by hand.
|
|
26
|
+
*
|
|
27
|
+
* Hand-maintained ON PURPOSE, and the one place in this file where that is
|
|
28
|
+
* right. The first version of this gate derived "the writes" from
|
|
29
|
+
* `isReadOnlyPath` and then asserted the grant set excluded them, which is the
|
|
30
|
+
* classifier agreeing with itself: mark `remove` as a read verb and both sides
|
|
31
|
+
* move together, so the test stays green while the console gains the ability to
|
|
32
|
+
* remove modules. Verified by doing exactly that (Rule 7.6). An assertion has to
|
|
33
|
+
* know something the code under test does not.
|
|
34
|
+
*/
|
|
35
|
+
const MUST_NEVER_BE_GRANTED = [
|
|
36
|
+
'module:deploy',
|
|
37
|
+
'module:remove',
|
|
38
|
+
'module:pause',
|
|
39
|
+
'module:unpause',
|
|
40
|
+
'module:import',
|
|
41
|
+
'module:update',
|
|
42
|
+
'module:config',
|
|
43
|
+
'module:secret',
|
|
44
|
+
'system:init',
|
|
45
|
+
'system:update',
|
|
46
|
+
'system:secret',
|
|
47
|
+
'machine:add',
|
|
48
|
+
'machine:remove',
|
|
49
|
+
'service:remove',
|
|
50
|
+
'backup:restore',
|
|
51
|
+
'api:grant',
|
|
52
|
+
'api:revoke',
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
describe('readOnlyGrants', () => {
|
|
56
|
+
test('grants none of the ops that change the fleet', () => {
|
|
57
|
+
const granted = new Set(readOnlyGrants(COMMANDS));
|
|
58
|
+
const leaked = MUST_NEVER_BE_GRANTED.filter((op) => granted.has(op));
|
|
59
|
+
expect(leaked).toEqual([]);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('the list above names ops the registry actually has', () => {
|
|
63
|
+
// Otherwise the gate decays into asserting things about commands that no
|
|
64
|
+
// longer exist, which passes forever and checks nothing.
|
|
65
|
+
const real = new Set(allOps(COMMANDS));
|
|
66
|
+
const stale = MUST_NEVER_BE_GRANTED.filter((op) => !real.has(op));
|
|
67
|
+
expect(stale).toEqual([]);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('a mutating verb added to the registry is NOT granted', () => {
|
|
71
|
+
// Rule 7.6: watch the gate work rather than trusting that it would. This is
|
|
72
|
+
// the registry shape a future command would introduce.
|
|
73
|
+
const withWrite: CommandDef[] = [
|
|
74
|
+
{
|
|
75
|
+
name: 'module',
|
|
76
|
+
description: 'module operations',
|
|
77
|
+
subcommands: [
|
|
78
|
+
{ name: 'list', description: 'list modules' },
|
|
79
|
+
{ name: 'obliterate', description: 'a new and very mutating verb' },
|
|
80
|
+
],
|
|
81
|
+
} as CommandDef,
|
|
82
|
+
];
|
|
83
|
+
|
|
84
|
+
const granted = readOnlyGrants(withWrite);
|
|
85
|
+
expect(granted).toContain('module:list');
|
|
86
|
+
expect(granted).not.toContain('module:obliterate');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('a read verb added to the registry IS granted, with no list to edit', () => {
|
|
90
|
+
const withRead: CommandDef[] = [
|
|
91
|
+
{
|
|
92
|
+
name: 'console',
|
|
93
|
+
description: 'console reads',
|
|
94
|
+
subcommands: [{ name: 'status', description: 'a newly added read verb' }],
|
|
95
|
+
} as CommandDef,
|
|
96
|
+
];
|
|
97
|
+
|
|
98
|
+
expect(readOnlyGrants(withRead)).toContain('console:status');
|
|
99
|
+
});
|
|
100
|
+
|
|
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.
|
|
105
|
+
expect(isReadOnlyPath(['module', 'config'])).toBe(false);
|
|
106
|
+
expect(readOnlyGrants(COMMANDS)).not.toContain('module:config');
|
|
107
|
+
});
|
|
108
|
+
});
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which command-registry leaves are reads, and the authz ops a read-only
|
|
3
|
+
* principal therefore needs.
|
|
4
|
+
*
|
|
5
|
+
* This lives in `@celilo/core`, beside `COMMANDS`, because it is a property of
|
|
6
|
+
* the command registry rather than of any one consumer. Two now derive from it:
|
|
7
|
+
* the MCP server routes each tool to its RO or RW principal, and the web console
|
|
8
|
+
* enrols a principal granted exactly the read ops.
|
|
9
|
+
*
|
|
10
|
+
* That shared derivation is the point. A hand-maintained grant list goes stale
|
|
11
|
+
* in the dangerous direction: a newly added read verb is merely missing, while a
|
|
12
|
+
* newly added WRITE verb silently lands inside a principal that was supposed to
|
|
13
|
+
* be read-only. Deriving from the registry means a new read verb is covered
|
|
14
|
+
* automatically and a new write verb is not.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { ArgDef, CommandDef, FlagDef } from './command-registry';
|
|
18
|
+
|
|
19
|
+
/** A runnable leaf command flattened from the registry tree. */
|
|
20
|
+
export interface Leaf {
|
|
21
|
+
/** Full path from a top-level command, e.g. `['module', 'list']`. */
|
|
22
|
+
path: string[];
|
|
23
|
+
description: string;
|
|
24
|
+
args: ArgDef[];
|
|
25
|
+
flags: FlagDef[];
|
|
26
|
+
}
|
|
27
|
+
|
|
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.
|
|
35
|
+
*/
|
|
36
|
+
export const READ_VERBS: ReadonlySet<string> = new Set([
|
|
37
|
+
'list',
|
|
38
|
+
'status',
|
|
39
|
+
'where',
|
|
40
|
+
'get',
|
|
41
|
+
// Reads a deployed module's daemon journal. Read-only by construction —
|
|
42
|
+
// the only remote command it can emit is `journalctl` (services/module-journal.ts).
|
|
43
|
+
'journal',
|
|
44
|
+
'audit',
|
|
45
|
+
'commands', // the MCP's own registry fetch rides the RO key
|
|
46
|
+
]);
|
|
47
|
+
|
|
48
|
+
/** The 2-level authz op a leaf runs as — `command:subcommand`, or bare command. */
|
|
49
|
+
export function opOf(path: string[]): string {
|
|
50
|
+
return path.length >= 2 ? `${path[0]}:${path[1]}` : path[0];
|
|
51
|
+
}
|
|
52
|
+
|
|
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];
|
|
56
|
+
}
|
|
57
|
+
|
|
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));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Flatten the registry tree into its runnable leaves (nodes with no subcommands). */
|
|
64
|
+
export function flattenLeaves(commands: CommandDef[], prefix: string[] = []): Leaf[] {
|
|
65
|
+
const leaves: Leaf[] = [];
|
|
66
|
+
for (const cmd of commands) {
|
|
67
|
+
const path = [...prefix, cmd.name];
|
|
68
|
+
if (cmd.subcommands && cmd.subcommands.length > 0) {
|
|
69
|
+
leaves.push(...flattenLeaves(cmd.subcommands, path));
|
|
70
|
+
} else {
|
|
71
|
+
leaves.push({
|
|
72
|
+
path,
|
|
73
|
+
description: cmd.description,
|
|
74
|
+
args: cmd.args ?? [],
|
|
75
|
+
flags: cmd.flags ?? [],
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return leaves;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Every authz op a read-only principal needs, derived from a command registry.
|
|
84
|
+
*
|
|
85
|
+
* Sorted and de-duplicated so the result is stable enough to compare in a test
|
|
86
|
+
* and to print in a grant line.
|
|
87
|
+
*/
|
|
88
|
+
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();
|
|
96
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reaching a celilo management server over SSH.
|
|
3
|
+
*
|
|
4
|
+
* Lives here rather than in any one client because every client needs the same
|
|
5
|
+
* two things and one of them is load-bearing safety. The MCP server and the web
|
|
6
|
+
* console server both drive `celilo-api@<server>`, both select a principal by
|
|
7
|
+
* key, and both are long-running processes that get restarted freely.
|
|
8
|
+
*
|
|
9
|
+
* That last fact is why this is shared rather than copied: Node does not kill
|
|
10
|
+
* spawned children when it exits, so every restart used to orphan whatever ssh
|
|
11
|
+
* clients were in flight. 656 of them accumulated on one machine and threw
|
|
12
|
+
* celilo-mgr into MaxStartups throttling, which is an SSH lockout of the control
|
|
13
|
+
* plane (celilo#921). A second copy of this code is a second chance to
|
|
14
|
+
* reintroduce that, and the console server has exactly the same lifecycle.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { type ChildProcess, spawn } from 'node:child_process';
|
|
18
|
+
import { Readable } from 'node:stream';
|
|
19
|
+
import type { RemoteTransport } from './remote-client';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Adapt a Node child process to the core `RemoteTransport` (web-stream) shape —
|
|
23
|
+
* plain `node:child_process`, no Bun runtime dependency, so published packages
|
|
24
|
+
* run on Node. Shared by the ssh transport (production) and by tests that spawn
|
|
25
|
+
* a local `api-serve`, so both exercise the same adapter.
|
|
26
|
+
*/
|
|
27
|
+
export function childProcessTransport(proc: ChildProcess): RemoteTransport {
|
|
28
|
+
if (!proc.stdout || !proc.stdin)
|
|
29
|
+
throw new Error('child process was not spawned with piped stdio');
|
|
30
|
+
return {
|
|
31
|
+
stdin: { write: (chunk: string) => void proc.stdin?.write(chunk) },
|
|
32
|
+
stdout: Readable.toWeb(proc.stdout) as unknown as ReadableStream<Uint8Array>,
|
|
33
|
+
kill: () => void proc.kill(),
|
|
34
|
+
exited: new Promise<number>((resolve) => proc.once('exit', (code) => resolve(code ?? 0))),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* ssh args to select a principal by key with no connection sharing.
|
|
40
|
+
*
|
|
41
|
+
* -T: no pty (raw NDJSON pipe). -i: force the principal's key. -o
|
|
42
|
+
* IdentitiesOnly=yes so a loaded agent key can't shadow the one we mean.
|
|
43
|
+
* ControlMaster=no + ControlPath=none: the RO and RW principals share one dest
|
|
44
|
+
* (celilo-api@<server>) and differ only by key, so an operator ssh config that
|
|
45
|
+
* multiplexes (Host * ControlMaster auto — %C hashes user+host, not the key)
|
|
46
|
+
* would collapse both onto whichever master connected first (RO), silently
|
|
47
|
+
* running every write as RO -> denied (126). Refusing to use or create a shared
|
|
48
|
+
* master keeps the principals isolated regardless of the operator's ssh config.
|
|
49
|
+
* (ce-h86)
|
|
50
|
+
*/
|
|
51
|
+
export function sshArgs(identityFile: string, dest: string): string[] {
|
|
52
|
+
return [
|
|
53
|
+
'-T',
|
|
54
|
+
'-o',
|
|
55
|
+
'IdentitiesOnly=yes',
|
|
56
|
+
'-o',
|
|
57
|
+
'ControlMaster=no',
|
|
58
|
+
'-o',
|
|
59
|
+
'ControlPath=none',
|
|
60
|
+
'-i',
|
|
61
|
+
identityFile,
|
|
62
|
+
dest,
|
|
63
|
+
];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* ssh children this process has open, so exiting doesn't abandon them (celilo#921).
|
|
68
|
+
*
|
|
69
|
+
* Node does not kill spawned children when it exits, and these servers are
|
|
70
|
+
* restarted freely. Every restart therefore orphaned whatever ssh clients were
|
|
71
|
+
* in flight: they reparent to init, keep their sockets ESTABLISHED, and keep an
|
|
72
|
+
* `api-serve` resident on the far end. 656 of them accumulated on one machine
|
|
73
|
+
* and threw celilo-mgr into MaxStartups throttling, an SSH lockout of the
|
|
74
|
+
* control plane.
|
|
75
|
+
*/
|
|
76
|
+
const liveSshChildren = new Set<ChildProcess>();
|
|
77
|
+
let reaperInstalled = false;
|
|
78
|
+
|
|
79
|
+
function reapOnExit(proc: ChildProcess): ChildProcess {
|
|
80
|
+
liveSshChildren.add(proc);
|
|
81
|
+
proc.once('exit', () => liveSshChildren.delete(proc));
|
|
82
|
+
if (!reaperInstalled) {
|
|
83
|
+
reaperInstalled = true;
|
|
84
|
+
const killAll = () => {
|
|
85
|
+
for (const child of liveSshChildren) child.kill();
|
|
86
|
+
};
|
|
87
|
+
process.once('exit', killAll);
|
|
88
|
+
// Installing a signal handler suppresses node's default termination, so
|
|
89
|
+
// these have to exit for us — reaping and then staying alive would be a
|
|
90
|
+
// worse leak than the one we are fixing.
|
|
91
|
+
for (const signal of ['SIGINT', 'SIGTERM'] as const) {
|
|
92
|
+
process.once(signal, () => {
|
|
93
|
+
killAll();
|
|
94
|
+
process.exit(128 + (signal === 'SIGINT' ? 2 : 15));
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return proc;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Build a `runRemoteClient` transport factory that selects a principal by key. */
|
|
102
|
+
export function sshTransportWith(identityFile: string): (dest: string) => RemoteTransport {
|
|
103
|
+
return (dest: string) =>
|
|
104
|
+
childProcessTransport(
|
|
105
|
+
reapOnExit(
|
|
106
|
+
spawn('ssh', sshArgs(identityFile, dest), {
|
|
107
|
+
stdio: ['pipe', 'pipe', 'inherit'],
|
|
108
|
+
}),
|
|
109
|
+
),
|
|
110
|
+
);
|
|
111
|
+
}
|