@dzhechkov/cloudru-hub 0.1.12 → 0.1.14

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.
@@ -0,0 +1,208 @@
1
+ 'use strict';
2
+
3
+ // An opt-in, exact-manifest approval for an existing Kubernetes cluster.
4
+ // The model never supplies the manifest, cluster, namespace or confirmation
5
+ // forwarded to the engine. The local operator reviews the file and its digest.
6
+ const crypto = require('crypto');
7
+ const fs = require('fs');
8
+ const path = require('path');
9
+ const { isDeepStrictEqual } = require('util');
10
+ const YAML = require('yaml');
11
+ const { readCredentials, UUID } = require('./credentials');
12
+ const { approvalRoot } = require('./deploy-approval');
13
+
14
+ const APPROVAL_ID = /^k8s-[0-9a-f]{12}$/;
15
+ const DNS_NAME = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
16
+ const DIGEST = /^[0-9a-f]{64}$/;
17
+ const MAX_MANIFEST = 64 * 1024;
18
+ const PILOT_IMAGE = 'docker.io/library/busybox@sha256:66a6306db78bf2dbf3487f293aa8d6990d8e506fdffab9cc43fe422becf886e4';
19
+ const isRecord = (x) => x !== null && typeof x === 'object' && !Array.isArray(x);
20
+ const sha256 = (raw) => crypto.createHash('sha256').update(raw).digest('hex');
21
+ function keys(value, allowed, label) {
22
+ if (!isRecord(value) || Object.keys(value).some((key) => !allowed.includes(key))) {
23
+ throw new Error(`${label} contains unsupported fields`);
24
+ }
25
+ }
26
+ function exact(value, expected, label) {
27
+ if (!isDeepStrictEqual(value, expected)) throw new Error(`${label} differs from the bounded Snake profile`);
28
+ }
29
+ function quantity(value, max, suffix = '') {
30
+ if (typeof value !== 'string' || !new RegExp(`^[1-9][0-9]{0,4}${suffix}$`).test(value)) return false;
31
+ return Number.parseInt(value, 10) <= max;
32
+ }
33
+
34
+ function privateDir(dir) {
35
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
36
+ const st = fs.lstatSync(dir);
37
+ if (!st.isDirectory() || st.isSymbolicLink() || (st.mode & 0o077) !== 0 ||
38
+ (typeof process.getuid === 'function' && st.uid !== process.getuid())) {
39
+ throw new Error('approval directory must be private and owned');
40
+ }
41
+ }
42
+
43
+ function privateJson(file) {
44
+ const st = fs.lstatSync(file);
45
+ if (!st.isFile() || st.isSymbolicLink() || (st.mode & 0o077) !== 0 || st.size > MAX_MANIFEST + 8192 ||
46
+ (typeof process.getuid === 'function' && st.uid !== process.getuid())) {
47
+ throw new Error('approval file must be private, owned and bounded');
48
+ }
49
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
50
+ }
51
+
52
+ function validateManifest(raw, namespace) {
53
+ if (typeof raw !== 'string' || Buffer.byteLength(raw) < 1 || Buffer.byteLength(raw) > MAX_MANIFEST) {
54
+ throw new Error('manifest must be non-empty and at most 64 KiB');
55
+ }
56
+ if (typeof namespace !== 'string' || !DNS_NAME.test(namespace) || namespace.length > 63) throw new Error('invalid namespace');
57
+ const docs = YAML.parseAllDocuments(raw, { uniqueKeys: true, maxAliasCount: 0 });
58
+ if (docs.length !== 3 || docs.some((d) => d.errors.length)) throw new Error('manifest must contain exactly three valid YAML documents');
59
+ const resources = docs.map((d) => d.toJS({ maxAliasCount: 0 }));
60
+ const byKind = new Map();
61
+ for (const r of resources) {
62
+ if (!isRecord(r) || !isRecord(r.metadata) || typeof r.metadata.name !== 'string' || !DNS_NAME.test(r.metadata.name) ||
63
+ r.metadata.namespace !== namespace || !['ConfigMap', 'Deployment', 'Service'].includes(r.kind) || byKind.has(r.kind)) {
64
+ throw new Error('manifest requires one namespaced ConfigMap, Deployment and Service with valid unique names');
65
+ }
66
+ if (r.apiVersion !== (r.kind === 'Deployment' ? 'apps/v1' : 'v1')) throw new Error('unexpected Kubernetes apiVersion');
67
+ keys(r.metadata, ['name', 'namespace'], 'metadata');
68
+ byKind.set(r.kind, r);
69
+ }
70
+ if (byKind.size !== 3) throw new Error('manifest requires ConfigMap, Deployment and Service');
71
+ const config = byKind.get('ConfigMap');
72
+ const deploy = byKind.get('Deployment');
73
+ const service = byKind.get('Service');
74
+ const app = deploy.metadata.name;
75
+ keys(config, ['apiVersion', 'kind', 'metadata', 'data'], 'ConfigMap');
76
+ keys(deploy, ['apiVersion', 'kind', 'metadata', 'spec'], 'Deployment');
77
+ keys(service, ['apiVersion', 'kind', 'metadata', 'spec'], 'Service');
78
+ if (config.metadata.name !== `${app}-html` || service.metadata.name !== app ||
79
+ !isRecord(config.data) || typeof config.data['index.html'] !== 'string' ||
80
+ Buffer.byteLength(config.data['index.html']) < 1 || Buffer.byteLength(config.data['index.html']) > 20 * 1024 ||
81
+ Object.keys(config.data).length !== 1 || config.binaryData !== undefined) {
82
+ throw new Error('ConfigMap must contain only bounded index.html for this app');
83
+ }
84
+ const spec = deploy.spec;
85
+ const pod = spec?.template?.spec;
86
+ const container = Array.isArray(pod?.containers) && pod.containers.length === 1 ? pod.containers[0] : null;
87
+ const image = container?.image;
88
+ keys(spec, ['replicas', 'selector', 'template'], 'Deployment spec');
89
+ keys(spec.selector, ['matchLabels'], 'Deployment selector');
90
+ keys(spec.template, ['metadata', 'spec'], 'pod template');
91
+ keys(spec.template.metadata, ['labels'], 'pod metadata');
92
+ keys(pod, ['automountServiceAccountToken', 'securityContext', 'containers', 'volumes'], 'pod spec');
93
+ if (container) keys(container, ['name', 'image', 'command', 'args', 'ports', 'readinessProbe', 'resources', 'securityContext', 'volumeMounts'], 'container');
94
+ keys(service.spec, ['type', 'selector', 'ports'], 'Service spec');
95
+ exact(spec.selector.matchLabels, { app }, 'Deployment selector');
96
+ exact(spec.template.metadata.labels, { app }, 'pod labels');
97
+ exact(service.spec.selector, { app }, 'Service selector');
98
+ exact(pod.securityContext, { runAsNonRoot: true }, 'pod securityContext');
99
+ if (container) {
100
+ exact(container.ports, [{ containerPort: 8080 }], 'container ports');
101
+ exact(container.securityContext, { allowPrivilegeEscalation: false, readOnlyRootFilesystem: true,
102
+ runAsUser: 10001, runAsGroup: 10001, capabilities: { drop: ['ALL'] } }, 'container securityContext');
103
+ exact(container.command, ['httpd'], 'container command');
104
+ exact(container.args, ['-f', '-p', '8080', '-h', '/www'], 'container args');
105
+ exact(container.readinessProbe, { httpGet: { path: '/', port: 8080 } }, 'readiness probe');
106
+ }
107
+ if (spec?.replicas !== 1 || spec?.selector?.matchLabels?.app !== app ||
108
+ spec?.template?.metadata?.labels?.app !== app || !isRecord(pod) ||
109
+ pod.hostNetwork || pod.hostPID || pod.hostIPC || pod.serviceAccountName ||
110
+ pod.automountServiceAccountToken !== false || pod.initContainers !== undefined ||
111
+ !container || container.name !== app || image !== PILOT_IMAGE ||
112
+ container.securityContext?.privileged === true || container.securityContext?.allowPrivilegeEscalation !== false ||
113
+ pod.securityContext?.runAsNonRoot !== true ||
114
+ container.readinessProbe?.httpGet?.path !== '/' || container.readinessProbe?.httpGet?.port !== 8080 ||
115
+ !isRecord(container.resources?.requests) || !isRecord(container.resources?.limits) ||
116
+ !container.resources.requests.cpu || !container.resources.requests.memory ||
117
+ !quantity(container.resources.requests.cpu, 500, 'm') || !quantity(container.resources.limits.cpu, 1000, 'm') ||
118
+ !quantity(container.resources.requests.memory, 512, 'Mi') || !quantity(container.resources.limits.memory, 1024, 'Mi') ||
119
+ Object.keys(container.resources.requests).length !== 2 || Object.keys(container.resources.limits).length !== 2 ||
120
+ Number.parseInt(container.resources.requests.cpu, 10) > Number.parseInt(container.resources.limits.cpu, 10) ||
121
+ Number.parseInt(container.resources.requests.memory, 10) > Number.parseInt(container.resources.limits.memory, 10) ||
122
+ !Array.isArray(pod.volumes) || pod.volumes.length !== 1 || pod.volumes[0]?.configMap?.name !== config.metadata.name ||
123
+ !Array.isArray(container.volumeMounts) || container.volumeMounts.length !== 1 ||
124
+ container.volumeMounts[0]?.name !== pod.volumes[0]?.name || !container.volumeMounts[0]?.readOnly ||
125
+ container.volumeMounts[0]?.mountPath !== '/www') {
126
+ throw new Error('Deployment must be a bounded non-root single-replica pod with pinned image, probes and resources');
127
+ }
128
+ exact(pod.volumes[0], { name: 'html', configMap: { name: config.metadata.name } }, 'pod volume');
129
+ exact(container.volumeMounts[0], { name: 'html', mountPath: '/www', readOnly: true }, 'volume mount');
130
+ exact(service.spec.ports, [{ port: 80, targetPort: 8080 }], 'Service ports');
131
+ if (service.spec?.type !== 'ClusterIP' || service.spec?.selector?.app !== app ||
132
+ !Array.isArray(service.spec?.ports) || service.spec.ports.length !== 1 ||
133
+ service.spec.ports[0]?.port !== 80 || service.spec.ports[0]?.targetPort !== 8080 ||
134
+ service.spec.externalIPs !== undefined || service.spec.loadBalancerIP !== undefined ||
135
+ service.spec.loadBalancerClass !== undefined) {
136
+ throw new Error('Service must be ClusterIP port 80 to pod port 8080 without external IPs');
137
+ }
138
+ return { app, resources: resources.map((r) => ({ kind: r.kind, name: r.metadata.name })), image };
139
+ }
140
+
141
+ function approvalFile(id, env = process.env) {
142
+ if (!APPROVAL_ID.test(id)) throw new Error('invalid Kubernetes approval ID');
143
+ return path.join(approvalRoot(env), `${id}.json`);
144
+ }
145
+
146
+ function issueK8sApproval(clusterId, namespace, manifestFile, reviewedSha256, env = process.env, now = Date.now()) {
147
+ if (!UUID.test(clusterId || '')) throw new Error('cluster_id must be a UUID');
148
+ if (!DIGEST.test(reviewedSha256 || '')) throw new Error('reviewed manifest SHA-256 is required');
149
+ const st = fs.lstatSync(manifestFile);
150
+ if (!st.isFile() || st.isSymbolicLink() || st.size < 1 || st.size > MAX_MANIFEST) {
151
+ throw new Error('manifest source must be a bounded regular file');
152
+ }
153
+ const manifest = fs.readFileSync(manifestFile, 'utf8');
154
+ const details = validateManifest(manifest, namespace);
155
+ const digest = sha256(manifest);
156
+ if (digest !== reviewedSha256) throw new Error('manifest digest differs from reviewed SHA-256');
157
+ const credentials = readCredentials(env);
158
+ const root = approvalRoot(env);
159
+ privateDir(root);
160
+ const id = `k8s-${crypto.randomBytes(6).toString('hex')}`;
161
+ const record = { id, projectId: credentials.projectId, clusterId, namespace, manifest, digest,
162
+ app: details.app, issuedAt: now, expiresAt: now + 10 * 60_000 };
163
+ fs.writeFileSync(approvalFile(id, env), JSON.stringify(record) + '\n', { flag: 'wx', mode: 0o600 });
164
+ return { approvalId: id, projectId: record.projectId, clusterId, namespace, manifestSha256: digest,
165
+ resources: details.resources, image: details.image, expiresAt: record.expiresAt,
166
+ estimateScope: 'existing cluster only; no cluster, worker, NAT or public load balancer is created' };
167
+ }
168
+
169
+ function consumeK8sApproval(id, env = process.env, now = Date.now()) {
170
+ const file = approvalFile(id, env);
171
+ const a = privateJson(file);
172
+ const credentials = readCredentials(env);
173
+ if (a.id !== id || a.projectId !== credentials.projectId || !UUID.test(a.clusterId || '') ||
174
+ !Number.isFinite(a.expiresAt) || a.expiresAt <= now || !DIGEST.test(a.digest || '') ||
175
+ sha256(a.manifest) !== a.digest || validateManifest(a.manifest, a.namespace).app !== a.app) {
176
+ throw new Error('Kubernetes approval does not match manifest, project or time');
177
+ }
178
+ const spent = path.join(approvalRoot(env), `${id}.used-${crypto.randomBytes(8).toString('hex')}.json`);
179
+ fs.renameSync(file, spent);
180
+ const session = { id, projectId: a.projectId, clusterId: a.clusterId, namespace: a.namespace,
181
+ app: a.app, expiresAt: now + 2 * 60 * 60_000 };
182
+ fs.writeFileSync(path.join(approvalRoot(env), `${id}.session.json`), JSON.stringify(session) + '\n', { flag: 'wx', mode: 0o600 });
183
+ return { clusterId: a.clusterId, namespace: a.namespace, manifest: a.manifest, app: a.app };
184
+ }
185
+
186
+ function k8sSession(id, env = process.env, now = Date.now()) {
187
+ if (!APPROVAL_ID.test(id)) throw new Error('invalid Kubernetes approval ID');
188
+ const s = privateJson(path.join(approvalRoot(env), `${id}.session.json`));
189
+ if (s.id !== id || s.projectId !== readCredentials(env).projectId || !UUID.test(s.clusterId || '') ||
190
+ typeof s.namespace !== 'string' || !DNS_NAME.test(s.namespace) ||
191
+ typeof s.app !== 'string' || !DNS_NAME.test(s.app) || !Number.isFinite(s.expiresAt) || s.expiresAt <= now) {
192
+ throw new Error('Kubernetes verification session is expired or mismatched');
193
+ }
194
+ return s;
195
+ }
196
+
197
+ function verificationArgs(session, query) {
198
+ const prefix = `-n ${session.namespace}`;
199
+ switch (query) {
200
+ case 'pods': return `${prefix} get pods -l app=${session.app} -o json`;
201
+ case 'deployment': return `${prefix} get deployment ${session.app} -o json`;
202
+ case 'service': return `${prefix} get service ${session.app} -o json`;
203
+ case 'rollout': return `${prefix} rollout status deployment/${session.app} --timeout=90s`;
204
+ default: throw new Error('query must be pods, deployment, service or rollout');
205
+ }
206
+ }
207
+
208
+ module.exports = { PILOT_IMAGE, validateManifest, issueK8sApproval, consumeK8sApproval, k8sSession, verificationArgs };
@@ -0,0 +1,38 @@
1
+ 'use strict';
2
+ // One-shot live tools/list probe. MCP servers may stay alive after stdin EOF;
3
+ // terminate the child after the first matching response instead of waiting for exit.
4
+ const { spawn } = require('child_process');
5
+
6
+ const bin = process.argv[2];
7
+ const child = spawn(bin, ['mcp'], { stdio: ['pipe', 'pipe', 'ignore'] });
8
+ let buffer = '';
9
+ let done = false;
10
+ const fail = (message) => {
11
+ if (done) return;
12
+ done = true;
13
+ process.stderr.write(message + '\n');
14
+ child.kill();
15
+ process.exitCode = 1;
16
+ };
17
+ child.on('error', (error) => fail(error.message));
18
+ child.on('exit', (code) => { if (!done) fail(`engine exited before tools/list: ${code}`); });
19
+ child.stdout.setEncoding('utf8');
20
+ child.stdout.on('data', (chunk) => {
21
+ if (done) return;
22
+ buffer += chunk;
23
+ if (Buffer.byteLength(buffer) > 16 * 1024 * 1024) { fail('tools/list response too large'); return; }
24
+ let end;
25
+ while ((end = buffer.indexOf('\n')) >= 0) {
26
+ const line = buffer.slice(0, end).trim();
27
+ buffer = buffer.slice(end + 1);
28
+ if (!line) continue;
29
+ let message;
30
+ try { message = JSON.parse(line); } catch { fail('engine emitted invalid JSON'); return; }
31
+ if (message.id !== 1) continue;
32
+ done = true;
33
+ process.stdout.write(JSON.stringify(message) + '\n');
34
+ child.kill();
35
+ return;
36
+ }
37
+ });
38
+ child.stdin.end(JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list' }) + '\n');
package/src/resolve.js CHANGED
@@ -4,7 +4,7 @@
4
4
  // 1. env CLOUDRU_VM_BIN — explicit path (local baseline testing)
5
5
  // 2. config enginePath — ~/.cloudru-hub/config.json (or $CLOUDRU_HUB_CONFIG)
6
6
  // 3. platform package @dzhechkov/cloudru-vm-<os>-<cpu>/native/cloudru-vm
7
- // (does not exist until the ADR-001 licence grant clears — see LICENSE Part 2)
7
+ // (installed automatically when its optional dependency is published)
8
8
  // 4. loud, esbuild-style error explaining all three paths and the licence hold.
9
9
  //
10
10
  // sha256 policy: the resolved binary is hashed and compared against the pinned map in
@@ -64,7 +64,7 @@ function resolveEngine(env = process.env) {
64
64
  const pkgJson = require.resolve(`${platformPkg}/package.json`);
65
65
  candidates.push({ path: path.join(path.dirname(pkgJson), 'native', 'cloudru-vm'), source: 'platform-package' });
66
66
  } catch {
67
- /* optional platform package not installed — expected while the ADR-001 hold is in force */
67
+ /* optional platform package unavailable on this host or omitted by the installer */
68
68
  }
69
69
  }
70
70
 
@@ -94,7 +94,7 @@ function resolveEngine(env = process.env) {
94
94
  ` 2. enginePath in ${configPath(env)} ${cfg.enginePath ? `(set, but not a file: ${cfg.enginePath})` : '(unset/absent)'}\n` +
95
95
  ` 3. optional dependency ${(PKG.cloudruHub && PKG.cloudruHub.platformPackages && PKG.cloudruHub.platformPackages[key]) || '<none for ' + key + '>'} (not installed)\n` +
96
96
  'Local testing: point $CLOUDRU_VM_BIN at an engine build (see docs/LOCAL-TESTING.md).\n' +
97
- 'The hosted platform package does not exist yet: its first publication still requires the ADR-002 trusted-CI binary build (see LICENSE Part 2 and THIRD_PARTY_NOTICES).',
97
+ 'If the platform package has been released, install optional dependencies; otherwise provide a pinned local engine build.',
98
98
  };
99
99
  }
100
100
 
@@ -0,0 +1,268 @@
1
+ 'use strict';
2
+
3
+ // Read-only MCP boundary for hosts whose hook/permission paths have not been
4
+ // live-probed. The host never gets a raw engine channel through this command.
5
+ const { spawn } = require('child_process');
6
+ const { loadClassification } = require('./classification');
7
+ const { resolveEngine } = require('./resolve');
8
+ const { selectedToolNames } = require('./toolsets');
9
+ const { readCredentials, readOptionalCredentials } = require('./credentials');
10
+ const { consumeApproval, recordDeployedVM, approvedVM } = require('./deploy-approval');
11
+ const { consumeK8sApproval, k8sSession, verificationArgs } = require('./k8s-approval');
12
+
13
+ const MAX_LINE = 1024 * 1024;
14
+ const idKey = (id) => JSON.stringify(id);
15
+ const errorResponse = (id, message, code = -32000) => ({ jsonrpc: '2.0', id, error: { code, message } });
16
+
17
+ function redactDeploySecrets(value) {
18
+ if (Array.isArray(value)) return value.map(redactDeploySecrets);
19
+ if (!value || typeof value !== 'object') return value;
20
+ return Object.fromEntries(Object.entries(value)
21
+ .filter(([key]) => !/password|secret|private.?key|access.?token/i.test(key))
22
+ .map(([key, item]) => [key, redactDeploySecrets(item)]));
23
+ }
24
+
25
+ function lineReader(stream, onLine, onError) {
26
+ let buffer = '';
27
+ stream.setEncoding('utf8');
28
+ stream.on('data', (chunk) => {
29
+ buffer += chunk;
30
+ if (Buffer.byteLength(buffer) > MAX_LINE * 2) return onError('MCP input exceeded line limit');
31
+ for (;;) {
32
+ const end = buffer.indexOf('\n');
33
+ if (end < 0) break;
34
+ const line = buffer.slice(0, end).trim();
35
+ buffer = buffer.slice(end + 1);
36
+ if (Buffer.byteLength(line) > MAX_LINE) return onError('MCP line exceeded limit');
37
+ if (line) onLine(line);
38
+ }
39
+ if (Buffer.byteLength(buffer) > MAX_LINE) onError('MCP line exceeded limit');
40
+ });
41
+ stream.on('end', () => { if (buffer.trim()) onError('incomplete MCP line'); });
42
+ }
43
+
44
+ function runSafeMcp(options = {}) {
45
+ const resolved = options.resolved || resolveEngine();
46
+ const cls = options.classification || loadClassification();
47
+ const stderr = options.stderr || process.stderr;
48
+ const stdin = options.stdin || process.stdin;
49
+ const stdout = options.stdout || process.stdout;
50
+ let selected;
51
+ const deployProfile = options.toolsets === 'vm-deploy';
52
+ const k8sProfile = options.toolsets === 'k8s-deploy';
53
+ let credentials;
54
+ try {
55
+ selected = new Set(selectedToolNames(options.toolsets || 'all', cls));
56
+ if (deployProfile || k8sProfile) credentials = readCredentials(options.env || process.env);
57
+ else if ([...selected].some((name) => name.startsWith('billing_'))) {
58
+ credentials = readOptionalCredentials(options.env || process.env);
59
+ }
60
+ }
61
+ catch (error) { stderr.write(`cloudru-hub mcp-safe: ${error.message}\n`); return 1; }
62
+ if (resolved.error || !resolved.verified || resolved.sha256 !== cls.engineSha256) {
63
+ stderr.write('cloudru-hub mcp-safe: pinned engine SHA-256 required; refusing to start\n');
64
+ return 1;
65
+ }
66
+
67
+ const engine = spawn(resolved.path, ['mcp'], {
68
+ stdio: ['pipe', 'pipe', 'inherit'],
69
+ env: credentials ? { ...(options.env || process.env), ...credentials.env } : (options.env || process.env),
70
+ });
71
+ const pending = new Map();
72
+ // The pinned engine's tool inventory is fixed for this process. Keep a
73
+ // filtered copy so a host keepalive can be answered while Go is handling a
74
+ // long deploy_apply request on its serial stdio loop.
75
+ let cachedToolsList = null;
76
+ let stopped = false;
77
+ let inputEnded = false;
78
+ let closing = false;
79
+ const emit = (obj) => stdout.write(JSON.stringify(obj) + '\n');
80
+ const stop = (reason) => {
81
+ if (stopped) return;
82
+ stopped = true;
83
+ stderr.write(`cloudru-hub mcp-safe: ${reason}\n`);
84
+ for (const key of pending.keys()) {
85
+ emit(errorResponse(JSON.parse(key), 'Cloud.ru engine connection closed'));
86
+ }
87
+ pending.clear();
88
+ stdin.destroy();
89
+ engine.stdin.end();
90
+ if (!engine.killed) engine.kill();
91
+ process.exitCode = 1;
92
+ };
93
+ engine.on('error', (error) => stop(`engine failed: ${error.message}`));
94
+ engine.on('exit', (code) => {
95
+ if (!stopped && !closing) stop(`engine exited with status ${code === null ? 'unknown' : code}`);
96
+ });
97
+ engine.stdin.on('error', (error) => stop(`engine input failed: ${error.message}`));
98
+ const closeIfDone = () => {
99
+ if (inputEnded && pending.size === 0 && !closing) {
100
+ closing = true;
101
+ engine.stdin.end();
102
+ }
103
+ };
104
+ stdin.on('end', () => { inputEnded = true; closeIfDone(); });
105
+
106
+ lineReader(stdin, (line) => {
107
+ if (stopped) return;
108
+ let message;
109
+ try { message = JSON.parse(line); } catch { emit(errorResponse(null, 'invalid JSON', -32700)); return; }
110
+ if (!message || Array.isArray(message) || typeof message !== 'object' || message.jsonrpc !== '2.0') {
111
+ emit(errorResponse(null, 'invalid JSON-RPC request', -32600)); return;
112
+ }
113
+ const hasId = Object.hasOwn(message, 'id');
114
+ const method = message.method;
115
+ if (!hasId) {
116
+ if (method === 'notifications/initialized') {
117
+ engine.stdin.write(JSON.stringify({ jsonrpc: '2.0', method }) + '\n');
118
+ }
119
+ return;
120
+ }
121
+ if (typeof method !== 'string' || (typeof message.id !== 'string' && !Number.isSafeInteger(message.id))) {
122
+ emit(errorResponse(message.id ?? null, 'invalid JSON-RPC request', -32600)); return;
123
+ }
124
+ const key = idKey(message.id);
125
+ if (pending.has(key)) { emit(errorResponse(message.id, 'duplicate request id', -32600)); return; }
126
+ if (method === 'tools/list' && cachedToolsList &&
127
+ (!Object.hasOwn(message, 'params') ||
128
+ (message.params && !Array.isArray(message.params) && typeof message.params === 'object' && Object.keys(message.params).length === 0))) {
129
+ emit({ jsonrpc: '2.0', id: message.id, result: cachedToolsList });
130
+ return;
131
+ }
132
+ let params;
133
+ let approval;
134
+ if (method === 'tools/call') {
135
+ const input = message.params;
136
+ const name = input && !Array.isArray(input) && typeof input === 'object' ? input.name : undefined;
137
+ if (typeof name !== 'string' || !selected.has(name)) {
138
+ emit(errorResponse(message.id, `tool ${typeof name === 'string' ? name : '<missing>'} is unavailable in this profile`));
139
+ return;
140
+ }
141
+ const args = input.arguments === undefined ? {} : input.arguments;
142
+ if (!args || typeof args !== 'object' || Array.isArray(args)) {
143
+ emit(errorResponse(message.id, 'tool arguments must be an object', -32602)); return;
144
+ }
145
+ // Go's encoding/json accepts case-insensitive struct field aliases. Never forward
146
+ // the original envelope: {name:"vm_list",Name:"deploy"} can otherwise execute deploy.
147
+ if (deployProfile && name === 'deploy_apply') {
148
+ try {
149
+ if (typeof args.plan_id !== 'string') throw new Error('plan_id is required');
150
+ approval = consumeApproval(args.plan_id, options.env || process.env);
151
+ params = { name, arguments: { plan_id: args.plan_id, confirmed: true } };
152
+ } catch (error) { emit(errorResponse(message.id, `deployment approval rejected: ${error.message}`)); return; }
153
+ } else if (deployProfile && name === 'vm_exec') {
154
+ try {
155
+ if (typeof args.command !== 'string' || !args.command.trim() || args.command.length > 65536) {
156
+ throw new Error('command must be a non-empty string of at most 65536 characters');
157
+ }
158
+ const vm = approvedVM(args.vm || args.vm_id, options.env || process.env);
159
+ params = { name, arguments: { vm: vm.vmId, user: vm.sshUser, command: args.command } };
160
+ } catch (error) { emit(errorResponse(message.id, `vm_exec rejected: ${error.message}`)); return; }
161
+ } else if (k8sProfile && name === 'k8s_apply') {
162
+ try {
163
+ if (Object.keys(args).length !== 1 || typeof args.approval_id !== 'string') throw new Error('only approval_id is accepted');
164
+ const approved = consumeK8sApproval(args.approval_id, options.env || process.env);
165
+ params = { name, arguments: { cluster_id: approved.clusterId, namespace: approved.namespace,
166
+ manifest: approved.manifest, create_only: true, confirmed: true } };
167
+ } catch (error) { emit(errorResponse(message.id, `Kubernetes approval rejected: ${error.message}`)); return; }
168
+ } else if (k8sProfile && name === 'k8s_kubectl') {
169
+ try {
170
+ if (Object.keys(args).length !== 2 || typeof args.approval_id !== 'string' || typeof args.query !== 'string') {
171
+ throw new Error('only approval_id and query are accepted');
172
+ }
173
+ const session = k8sSession(args.approval_id, options.env || process.env);
174
+ params = { name, arguments: { cluster_id: session.clusterId, args: verificationArgs(session, args.query) } };
175
+ } catch (error) { emit(errorResponse(message.id, `Kubernetes verification rejected: ${error.message}`)); return; }
176
+ } else {
177
+ params = { name, arguments: args };
178
+ }
179
+ } else if (!['initialize', 'ping', 'tools/list'].includes(method)) {
180
+ emit(errorResponse(message.id, `method ${method} is unavailable in read-only mode`, -32601));
181
+ return;
182
+ } else if (method === 'initialize') {
183
+ // The pinned engine requires the client's protocol version and capabilities.
184
+ const p = message.params;
185
+ if (!p || typeof p !== 'object' || Array.isArray(p)) {
186
+ emit(errorResponse(message.id, 'initialize params must be an object', -32602)); return;
187
+ }
188
+ params = { protocolVersion: p.protocolVersion, capabilities: p.capabilities, clientInfo: p.clientInfo };
189
+ }
190
+ pending.set(key, { method, approval });
191
+ engine.stdin.write(JSON.stringify({ jsonrpc: '2.0', id: message.id, method, ...(params ? { params } : {}) }) + '\n');
192
+ }, stop);
193
+
194
+ lineReader(engine.stdout, (line) => {
195
+ if (stopped) return;
196
+ let message;
197
+ try { message = JSON.parse(line); } catch { stop('engine emitted invalid JSON'); return; }
198
+ if (!message || typeof message !== 'object' || !Object.hasOwn(message, 'id')) {
199
+ // MCP progress/log notifications are not needed for the safe read-only surface.
200
+ return;
201
+ }
202
+ const key = idKey(message.id);
203
+ const request = pending.get(key);
204
+ if (!request) { stop('engine emitted an unsolicited response'); return; }
205
+ const { method, approval } = request;
206
+ pending.delete(key);
207
+ if (method === 'tools/list' && !message.error) {
208
+ if (!message.result || !Array.isArray(message.result.tools)) { stop('engine tools/list response malformed'); return; }
209
+ message.result = { ...message.result, tools: message.result.tools.filter((tool) =>
210
+ tool && typeof tool.name === 'string' && selected.has(tool.name)).map((tool) => {
211
+ if (!k8sProfile) return tool;
212
+ if (tool.name === 'k8s_apply') return { ...tool,
213
+ description: 'Apply one locally reviewed Snake manifest to one existing cluster. Pass only the local approval ID.',
214
+ inputSchema: { type: 'object', properties: { approval_id: { type: 'string' } }, required: ['approval_id'], additionalProperties: false } };
215
+ if (tool.name === 'k8s_kubectl') return { ...tool,
216
+ description: 'Read only pods, deployment, service or rollout status for the previously approved Snake workload.',
217
+ inputSchema: { type: 'object', properties: { approval_id: { type: 'string' },
218
+ query: { type: 'string', enum: ['pods', 'deployment', 'service', 'rollout'] } },
219
+ required: ['approval_id', 'query'], additionalProperties: false } };
220
+ return tool;
221
+ }) };
222
+ // A paginated list could conceal tools from filtering/coverage checks.
223
+ if (message.result.nextCursor) { stop('engine tools/list pagination is unsupported'); return; }
224
+ cachedToolsList = message.result;
225
+ }
226
+ if (method === 'initialize' && message.result && message.result.capabilities) {
227
+ message.result = { ...message.result, capabilities: { tools: message.result.capabilities.tools || {} } };
228
+ }
229
+ if (approval && !message.error) {
230
+ // The engine returns an MCP text result containing its JSON deploy result.
231
+ // Keep the model from seeing a generated login password: vm_exec uses the
232
+ // engine-managed SSH key. Ambiguous responses require inspection.
233
+ const item = message.result?.content?.find((x) => x.type === 'text');
234
+ let deploy;
235
+ try { deploy = JSON.parse(item?.text || ''); }
236
+ catch {
237
+ message.result = { isError: true, content: [{ type: 'text', text: 'deploy_apply returned an unreadable outcome; inspect the live VM inventory before retrying' }] };
238
+ emit(message); closeIfDone(); return;
239
+ }
240
+ if (!deploy || typeof deploy !== 'object' || Array.isArray(deploy)) {
241
+ message.result = { isError: true, content: [{ type: 'text', text: 'deploy_apply returned an invalid outcome; inspect the live VM inventory before retrying' }] };
242
+ emit(message); closeIfDone(); return;
243
+ }
244
+ const safe = redactDeploySecrets(deploy);
245
+ if (!message.result?.isError && approval.siteMode && deploy.status === 'running' && deploy.http_verification?.verified === true) {
246
+ // Static-site delivery is verified over HTTP. It does not imply SSH is
247
+ // available, so never create a vm_exec session for this VM.
248
+ } else if (!message.result?.isError && !approval.siteMode && deploy.status === 'running') {
249
+ try {
250
+ const session = recordDeployedVM(approval, deploy, options.env || process.env);
251
+ safe.approved_vm_session_expires_at = new Date(session.expiresAt).toISOString();
252
+ } catch (error) {
253
+ message.result.isError = true;
254
+ safe.recovery = `VM may exist but the approved execution session was not recorded: ${error.message}. Inspect VM inventory before retrying.`;
255
+ }
256
+ } else {
257
+ message.result.isError = true;
258
+ safe.recovery = 'A VM may exist. Inspect the returned VM UUID and live inventory before retrying; a fresh plan and approval may be needed.';
259
+ }
260
+ item.text = JSON.stringify(safe);
261
+ }
262
+ emit(message);
263
+ closeIfDone();
264
+ }, stop);
265
+ return null;
266
+ }
267
+
268
+ module.exports = { runSafeMcp, MAX_LINE };
@@ -0,0 +1,58 @@
1
+ 'use strict';
2
+ // Named, auditable discovery groups. Each member must remain classified allow.
3
+ const { loadClassification } = require('./classification');
4
+
5
+ const GROUPS = Object.freeze({
6
+ inventory: ['status', 'list', 'vm_list', 'vm_get', 'list_zones', 'list_images', 'list_flavors', 'list_disk_types', 'list_subnets', 'org_projects', 'org_quotas'],
7
+ compute: ['vm_list', 'vm_get', 'disk_list', 'list_flavors', 'list_disk_types', 'list_zones', 'list_images', 'list_subnets', 'list_security_groups', 'list_floating_ips'],
8
+ kubernetes: ['k8s_list', 'k8s_get', 'k8s_options', 'k8s_plan', 'k8s_nodepools'],
9
+ data: ['pg_list', 'pg_get', 'pg_versions', 'pg_specs', 'pg_databases', 'pg_users', 'pg_backups', 'kafka_list', 'kafka_get', 'kafka_versions', 'kafka_specs', 'redis_list', 'redis_get', 'redis_versions', 'redis_specs'],
10
+ network: ['vpc_list', 'vpc_get', 'vpc_routes', 'snat_list', 'lb_list', 'lb_get', 'tg_list', 'tg_get', 'dns_zones', 'dns_zone_get', 'dns_records', 'dns_record_get'],
11
+ storage: ['disk_list', 's3_buckets', 's3_ls', 's3_get'],
12
+ billing: ['billing_balance', 'billing_consumption', 'billing_finops'],
13
+ });
14
+
15
+ // A separate, opt-in workflow. Mutators pass the proxy's local plan/VM guard;
16
+ // combining this with a read-only group would blur that boundary.
17
+ const VM_DEPLOY = Object.freeze([
18
+ 'deploy_plan', 'deploy_apply', 'vm_exec', 'vm_list', 'vm_get',
19
+ 'list_zones', 'list_images', 'list_flavors', 'list_disk_types',
20
+ 'list_subnets', 'list_security_groups', 'status',
21
+ ]);
22
+ const K8S_DEPLOY = Object.freeze([
23
+ 'k8s_list', 'k8s_get', 'k8s_options', 'k8s_nodepools', 'k8s_apply', 'k8s_kubectl',
24
+ ]);
25
+
26
+ function selectedToolNames(spec = 'all', cls = loadClassification()) {
27
+ if (spec === 'vm-deploy') {
28
+ for (const name of VM_DEPLOY) {
29
+ const permission = cls.tools[name]?.permission;
30
+ if (permission !== (['deploy_apply', 'vm_exec'].includes(name) ? 'ask' : 'allow')) {
31
+ throw new Error(`vm-deploy tool classification changed: ${name}`);
32
+ }
33
+ }
34
+ return [...VM_DEPLOY].sort();
35
+ }
36
+ if (spec === 'k8s-deploy') {
37
+ for (const name of K8S_DEPLOY) {
38
+ const permission = cls.tools[name]?.permission;
39
+ if (permission !== (['k8s_apply', 'k8s_kubectl'].includes(name) ? 'ask' : 'allow')) {
40
+ throw new Error(`k8s-deploy tool classification changed: ${name}`);
41
+ }
42
+ }
43
+ return [...K8S_DEPLOY].sort();
44
+ }
45
+ const names = Object.entries(cls.tools).filter(([, v]) => v.permission === 'allow').map(([name]) => name);
46
+ if (spec === 'all') return names.sort();
47
+ const parts = String(spec).split(',').map((x) => x.trim()).filter(Boolean);
48
+ if (parts.length === 0 || parts.includes('all') || parts.some((p) => !Object.hasOwn(GROUPS, p))) {
49
+ throw new Error(`invalid toolsets "${spec}"; choose all, vm-deploy, k8s-deploy, or comma-separated ${Object.keys(GROUPS).join(', ')}`);
50
+ }
51
+ const selected = [...new Set(parts.flatMap((p) => GROUPS[p]))].sort();
52
+ for (const name of selected) {
53
+ if (!cls.tools[name] || cls.tools[name].permission !== 'allow') throw new Error(`toolset contains non-allow tool: ${name}`);
54
+ }
55
+ return selected;
56
+ }
57
+
58
+ module.exports = { GROUPS, VM_DEPLOY, K8S_DEPLOY, selectedToolNames };
File without changes