@dzhechkov/cloudru-hub 0.1.12 → 0.1.13

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/src/install.js CHANGED
@@ -1,12 +1,12 @@
1
1
  'use strict';
2
- // Per-target installer — ADR-005 tiering (tier derived from flow requirements R1–R5,
3
- // live-probe discipline) + ADR-004 brake emission (permission ask/deny + PreToolUse veto).
2
+ // Per-target installer — Claude settings plus guarded MCP registrations for other hosts.
4
3
  //
5
4
  // Tier map (ADR-005 Decision, applied verbatim):
6
5
  // claude-code FULL candidate — full install path (this module emits it)
7
- // codex FULL candidate #2 — plan printed, NOT auto-applied (delivery requires an
8
- // EXECUTED veto probe; project hooks.json is a measured silent no-op)
9
- // openclaude / opencode / cursor / windsurf / copilot
6
+ // codex / opencode / pi — project-scoped, pinned read-only MCP proxy;
7
+ // host activation still needs a live probe
8
+ // hermes guarded MCP entry in $HERMES_HOME/config.yaml (complex YAML refused)
9
+ // openclaude / cursor / windsurf / copilot
10
10
  // DEGRADED-until-proven — installer REFUSES (no live probe delivered yet)
11
11
  // agents-md / gemini
12
12
  // POINTER-ONLY — ≤2048-char pointer + no-veto warning, never a fake install
@@ -16,18 +16,25 @@
16
16
  // (ADR-004/ADR-005: "наличие файла хука — не evidence"; layout ≠ registration).
17
17
 
18
18
  const fs = require('fs');
19
+ const os = require('os');
19
20
  const path = require('path');
21
+ const { isDeepStrictEqual } = require('util');
20
22
  const { spawnSync } = require('child_process');
21
- const { permissionRules } = require('./classification');
23
+ const YAML = require('yaml');
24
+ const { permissionRules, loadClassification } = require('./classification');
25
+ const { resolveEngine } = require('./resolve');
26
+ const { selectedToolNames } = require('./toolsets');
22
27
 
23
28
  const HOOK_TEMPLATE = path.join(__dirname, '..', 'templates', '.claude', 'hooks', 'cloudru-ssh-guard.cjs');
24
29
  const POINTER_TEMPLATE = path.join(__dirname, '..', 'data', 'pointer-section.md');
25
30
 
26
31
  const TIERS = {
27
32
  'claude-code': 'full',
28
- codex: 'plan-only',
33
+ codex: 'safe-mcp',
34
+ hermes: 'safe-mcp',
35
+ opencode: 'safe-mcp',
36
+ pi: 'safe-mcp',
29
37
  openclaude: 'degraded',
30
- opencode: 'degraded',
31
38
  cursor: 'degraded',
32
39
  windsurf: 'degraded',
33
40
  copilot: 'degraded',
@@ -37,7 +44,6 @@ const TIERS = {
37
44
 
38
45
  const DEGRADED_REASONS = {
39
46
  openclaude: 'all three legs exist upstream (.mcp.json, PreToolUse deny, .openclaude/skills) but NOTHING has been probed live — degraded-until-proven (ADR-005).',
40
- opencode: 'MCP + veto plugins are documented, zero live runs; R4 (tool budget deferral) unverified (ADR-005).',
41
47
  cursor: 'veto hooks documented only; R3 broken by the .mdc adapter, R4 truncation history (~40 tools) unresolved (ADR-005).',
42
48
  windsurf: 'R4 FAILS as-is: Cascade budget 100 tools < 144 — needs the engine tool-profile before any delivery (ADR-005).',
43
49
  copilot: 'only Copilot CLI is a candidate; the coding agent fails R2 by construction ("will not ask for your approval" + hook timeout fail-open) (ADR-005).',
@@ -118,6 +124,221 @@ function writeJson(file, obj) {
118
124
  fs.writeFileSync(file, JSON.stringify(obj, null, 2) + '\n');
119
125
  }
120
126
 
127
+ const SAFE_SERVER = 'cloudru_hub';
128
+ const longRunningProfile = (toolsets) => toolsets === 'vm-deploy' || toolsets === 'k8s-deploy';
129
+ const record = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
130
+
131
+ function safeCommand(toolsets) {
132
+ return { command: process.execPath, args: [path.join(__dirname, '..', 'bin', 'cloudru-hub.js'), 'mcp-safe', '--toolsets', toolsets] };
133
+ }
134
+
135
+ function installClaudeK8s(projectDir, opts = {}) {
136
+ if (opts.npx) return { code: 2, lines: ['cloudru-hub install: k8s-deploy requires the local guarded launcher; npx package publication is not assumed.'] };
137
+ const file = path.join(projectDir, '.mcp.json');
138
+ const settingsFile = path.join(projectDir, '.claude', 'settings.json');
139
+ if ((fs.existsSync(file) && !readJson(file)) || (fs.existsSync(settingsFile) && !readJson(settingsFile))) {
140
+ return { code: 2, lines: ['cloudru-hub install: existing Claude JSON is invalid; refusing to replace it.'] };
141
+ }
142
+ const mcp = readJson(file) || {};
143
+ if (!record(mcp)) return { code: 2, lines: ['cloudru-hub install: Claude MCP JSON root must be an object.'] };
144
+ if (mcp.mcpServers && !record(mcp.mcpServers)) {
145
+ return { code: 2, lines: ['cloudru-hub install: existing Claude MCP server map is invalid.'] };
146
+ }
147
+ const servers = mcp.mcpServers || {};
148
+ if (servers['cloudru-vm'] || servers.cloudru_vm) return { code: 2, lines: ['cloudru-hub install: raw Cloud.ru MCP server is present; remove it from this isolated project before enabling k8s-deploy.'] };
149
+ const cmd = safeCommand('k8s-deploy');
150
+ const desired = { command: cmd.command, args: cmd.args };
151
+ if (servers[SAFE_SERVER] && !isDeepStrictEqual(servers[SAFE_SERVER], desired)) {
152
+ return { code: 2, lines: ['cloudru-hub install: existing cloudru_hub server differs from the guarded k8s-deploy command.'] };
153
+ }
154
+ const settings = readJson(settingsFile) || {};
155
+ if (!record(settings)) return { code: 2, lines: ['cloudru-hub install: Claude settings JSON root must be an object.'] };
156
+ if (settings.enabledMcpjsonServers && !Array.isArray(settings.enabledMcpjsonServers)) {
157
+ return { code: 2, lines: ['cloudru-hub install: existing Claude enabled server list is invalid.'] };
158
+ }
159
+ if (settings.enabledMcpjsonServers?.some((name) => name === 'cloudru-vm' || name === 'cloudru_vm')) {
160
+ return { code: 2, lines: ['cloudru-hub install: raw Cloud.ru MCP server is enabled in Claude settings; use an isolated project.'] };
161
+ }
162
+ mcp.mcpServers = { ...servers, [SAFE_SERVER]: desired };
163
+ writeJson(file, mcp);
164
+ writeJson(settingsFile, { ...settings, enabledMcpjsonServers: unionSorted(settings.enabledMcpjsonServers, [SAFE_SERVER]) });
165
+ return { code: 0, lines: [`cloudru-hub install: guarded k8s-deploy server written to ${file}.`,
166
+ ' Claude Code activation and actual agent calls remain unverified until inference access returns.'] };
167
+ }
168
+
169
+ function installJsonMcp(target, projectDir, toolsets) {
170
+ const opencode = target === 'opencode';
171
+ const file = path.join(projectDir, opencode ? 'opencode.json' : '.pi/mcp.json');
172
+ if (opencode && fs.existsSync(path.join(projectDir, 'opencode.jsonc'))) {
173
+ return { code: 2, lines: ['cloudru-hub install: REFUSED — opencode.jsonc exists; merge the safe server manually to avoid competing project configs.'] };
174
+ }
175
+ let config = {};
176
+ if (fs.existsSync(file)) {
177
+ try { config = JSON.parse(fs.readFileSync(file, 'utf8')); }
178
+ catch { return { code: 2, lines: [`cloudru-hub install: REFUSED — ${file} is not strict JSON; no changes written.`] }; }
179
+ }
180
+ if (!config || Array.isArray(config) || typeof config !== 'object') return { code: 2, lines: ['cloudru-hub install: REFUSED — config root is not an object.'] };
181
+ const parent = opencode ? config.mcp : config.mcpServers;
182
+ if (parent != null && (Array.isArray(parent) || typeof parent !== 'object')) return { code: 2, lines: ['cloudru-hub install: REFUSED — MCP config is not an object.'] };
183
+ const servers = parent || {};
184
+ if (servers['cloudru-vm'] || servers.cloudru_vm) return { code: 2, lines: ['cloudru-hub install: REFUSED — a raw cloudru-vm server already exists; remove it before enabling the safe server.'] };
185
+ const cmd = safeCommand(toolsets);
186
+ const entry = opencode
187
+ ? { type: 'local', command: [cmd.command, ...cmd.args], enabled: true, ...(longRunningProfile(toolsets) ? { timeout: 1_200_000 } : {}) }
188
+ : { ...cmd, enabled: true, ...(longRunningProfile(toolsets) ? { timeout: 1200 } : {}), description: toolsets === 'vm-deploy' ? 'Pinned Cloud.ru VM deployment with local plan approval' : toolsets === 'k8s-deploy' ? 'Pinned Cloud.ru Kubernetes deployment with exact-manifest approval' : 'Pinned Cloud.ru MCP tools with cloud mutations blocked' };
189
+ if (servers[SAFE_SERVER] && JSON.stringify(servers[SAFE_SERVER]) !== JSON.stringify(entry)) {
190
+ return { code: 2, lines: [`cloudru-hub install: REFUSED — ${SAFE_SERVER} already has a different configuration.`] };
191
+ }
192
+ servers[SAFE_SERVER] = entry;
193
+ if (opencode) config.mcp = servers;
194
+ else config.mcpServers = servers;
195
+ writeJson(file, config);
196
+ return { code: 0, lines: [`cloudru-hub install: wrote ${file} with ${SAFE_SERVER} → mcp-safe.`, ' Activation requires a trusted project and a host-side connection probe; no live host claim made.'] };
197
+ }
198
+
199
+ function installCodex(projectDir, toolsets) {
200
+ const file = path.join(projectDir, '.codex', 'config.toml');
201
+ const existing = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : '';
202
+ const cmd = safeCommand(toolsets);
203
+ const tools = selectedToolNames(toolsets);
204
+ const inheritedEnv = longRunningProfile(toolsets)
205
+ ? ['CLOUDRU_VM_BIN', 'CLOUDRU_HUB_CREDENTIALS', 'CLOUDRU_VM_HOME', 'CLOUDRU_HUB_APPROVALS']
206
+ : ['CLOUDRU_VM_BIN'];
207
+ if (tools.some((name) => name.startsWith('billing_'))) {
208
+ for (const name of ['CLOUDRU_HUB_CREDENTIALS', 'CLOUDRU_AGREEMENT_ID', 'CLOUDRU_KEY_ID', 'CLOUDRU_SECRET', 'CLOUDRU_PROJECT_ID', 'CLOUDRU_REGION']) {
209
+ if (!inheritedEnv.includes(name)) inheritedEnv.push(name);
210
+ }
211
+ }
212
+ const entry = `# cloudru-hub safe BEGIN\n[mcp_servers.${SAFE_SERVER}]\ncommand = ${JSON.stringify(cmd.command)}\nargs = ${JSON.stringify(cmd.args)}\nenv_vars = ${JSON.stringify(inheritedEnv)}\nrequired = true\nenabled_tools = ${JSON.stringify(tools)}\ndefault_tools_approval_mode = ${JSON.stringify(longRunningProfile(toolsets) ? 'auto' : 'prompt')}\n${longRunningProfile(toolsets) ? 'tool_timeout_sec = 1200\n' : ''}# cloudru-hub safe END\n`;
213
+ // TOML has quoted/dotted keys and inline tables. A regex that checks only a
214
+ // bare section name can leave a raw server enabled alongside this one. The
215
+ // zero-dependency installer therefore owns its exact block and refuses any
216
+ // other MCP declaration for manual review rather than guessing its meaning.
217
+ const remainder = existing.includes(entry) ? existing.replace(entry, '') : existing;
218
+ if (/mcp_servers|^\s*(?:\[\s*["']|["'][^"']+["']\s*=)|\\u[0-9a-f]{4}/im.test(remainder)) {
219
+ return { code: 2, lines: ['cloudru-hub install: REFUSED — existing MCP/TOML syntax needs manual review before adding a safe server.'] };
220
+ }
221
+ if (remainder !== existing) return { code: 0, lines: [`cloudru-hub install: ${file} already contains the safe server.`] };
222
+ fs.mkdirSync(path.dirname(file), { recursive: true });
223
+ fs.writeFileSync(file, existing.trimEnd() + '\n' + entry);
224
+ return { code: 0, lines: [`cloudru-hub install: wrote ${file} with ${tools.length} classified tools and mcp-safe.`, ' Codex loads project config only in a trusted project; run a live host probe before claiming activation.'] };
225
+ }
226
+
227
+ function hermesBlock(toolsets, enginePath, indent = ' ') {
228
+ const cmd = safeCommand(toolsets);
229
+ const include = selectedToolNames(toolsets);
230
+ return [
231
+ `${indent}# cloudru-hub safe BEGIN`,
232
+ `${indent}${SAFE_SERVER}:`,
233
+ `${indent}${indent}command: ${JSON.stringify(cmd.command)}`,
234
+ `${indent}${indent}args: ${JSON.stringify(cmd.args)}`,
235
+ `${indent}${indent}env:`,
236
+ `${indent}${indent}${indent}CLOUDRU_VM_BIN: ${JSON.stringify(enginePath)}`,
237
+ `${indent}${indent}enabled: true`,
238
+ `${indent}${indent}timeout: ${longRunningProfile(toolsets) ? 1200 : 120}`,
239
+ `${indent}${indent}tools:`,
240
+ `${indent}${indent}${indent}include: [${include.map((name) => JSON.stringify(name)).join(', ')}]`,
241
+ `${indent}# cloudru-hub safe END`,
242
+ ].join('\n') + '\n';
243
+ }
244
+
245
+ function installHermes(toolsets, opts = {}) {
246
+ const home = path.resolve(opts.hermesHome || process.env.HERMES_HOME || path.join(os.homedir(), '.hermes'));
247
+ const file = path.join(home, 'config.yaml');
248
+ const existing = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : '';
249
+ const refuse = (reason) => ({ code: 2, lines: [`cloudru-hub install: REFUSED — ${reason}; ${file} was not changed.`] });
250
+ let original;
251
+ let before;
252
+ try {
253
+ original = YAML.parseDocument(existing, { uniqueKeys: true });
254
+ if (original.errors.length || (original.contents && (!YAML.isMap(original.contents) || original.contents.flow))) {
255
+ return refuse('existing YAML is invalid or not a block mapping');
256
+ }
257
+ before = original.toJS() || {};
258
+ if (before.mcp_servers != null && (Array.isArray(before.mcp_servers) || typeof before.mcp_servers !== 'object')) {
259
+ return refuse('mcp_servers is not a mapping');
260
+ }
261
+ } catch { return refuse('existing YAML could not be parsed'); }
262
+ if (/\t|^---\s*$|^\.\.\.\s*$/m.test(existing)) return refuse('complex YAML needs manual review');
263
+ const sections = [...existing.matchAll(/^mcp_servers:\s*(?:#.*)?$/gm)];
264
+ if (sections.length > 1) return refuse('duplicate mcp_servers sections');
265
+ if (!sections.length && /^mcp_servers\s*:/m.test(existing)) return refuse('mcp_servers is not a plain top-level mapping');
266
+ if (!sections.length && /(?:^|\n)\s*["']?mcp_servers["']?\s*:/m.test(existing)) return refuse('complex mcp_servers syntax needs manual review');
267
+ // YAML duplicate keys may be accepted with last-value-wins semantics. A
268
+ // quoted second mcp_servers key could hide the server we just inserted.
269
+ if ((existing.match(/mcp_servers/g) || []).length !== sections.length || /\\[uxU][0-9a-f]+/i.test(existing)) {
270
+ return refuse('duplicate or escaped YAML keys need manual review');
271
+ }
272
+
273
+ let indent = ' ';
274
+ let insertAt = existing.length;
275
+ let blockText = '';
276
+ if (sections.length) {
277
+ const section = sections[0];
278
+ const contentStart = section.index + section[0].length + (existing[section.index + section[0].length] === '\n' ? 1 : 0);
279
+ const next = /^\S[^\n]*$/gm;
280
+ next.lastIndex = contentStart;
281
+ const match = next.exec(existing);
282
+ insertAt = match ? match.index : existing.length;
283
+ blockText = existing.slice(contentStart, insertAt);
284
+ const indents = [...blockText.matchAll(/^([ ]+)(\S.*)$/gm)]
285
+ .filter((m) => !m[2].startsWith('#')).map((m) => m[1].length);
286
+ if (indents.length) {
287
+ const min = Math.min(...indents);
288
+ indent = ' '.repeat(min);
289
+ if (min > 8) return refuse('unusual mcp_servers indentation');
290
+ }
291
+ } else if (/\b(?:cloudru[-_]vm|cloudru_hub)\b/i.test(existing)) {
292
+ return refuse('Cloud.ru configuration is already mentioned');
293
+ }
294
+
295
+ // Hermes deliberately strips non-baseline environment variables before
296
+ // starting MCP servers. Resolve the engine now and pass only its path in
297
+ // the server-specific env; the proxy hashes the file again at runtime.
298
+ const engine = opts.resolvedEngine || resolveEngine();
299
+ if (engine.error || !engine.verified || !engine.path || engine.sha256 !== loadClassification().engineSha256) {
300
+ return refuse('a locally available, hash-verified Cloud.ru engine is required for Hermes');
301
+ }
302
+ const enginePath = path.resolve(engine.path);
303
+ const block = hermesBlock(toolsets, enginePath, indent);
304
+ if (/\b(?:cloudru[-_]vm|cloudru_hub)\b/i.test(existing.replace(block, ''))) {
305
+ return refuse('a Cloud.ru server is already mentioned outside this exact safe block');
306
+ }
307
+ if (blockText.includes('# cloudru-hub safe BEGIN')) {
308
+ const remainder = blockText.replace(block, '');
309
+ if (remainder !== blockText && !/\b(?:cloudru[-_]vm|cloudru_hub)\b|[&*]|<<\s*:/i.test(remainder)) {
310
+ return { code: 0, lines: [`cloudru-hub install: ${file} already contains the safe server.`] };
311
+ }
312
+ return refuse('existing cloudru-hub block differs from the requested toolset');
313
+ }
314
+ if (/\b(?:cloudru[-_]vm|cloudru_hub)\b|[&*]|<<\s*:/i.test(blockText)) return refuse('Cloud.ru server or YAML aliases already occur in mcp_servers');
315
+ const updated = sections.length
316
+ ? existing.slice(0, insertAt) + (insertAt && existing[insertAt - 1] !== '\n' ? '\n' : '') + block + existing.slice(insertAt)
317
+ : existing.trimEnd() + (existing.trim() ? '\n\n' : '') + 'mcp_servers:\n' + block;
318
+ try {
319
+ const candidate = YAML.parseDocument(updated, { uniqueKeys: true });
320
+ if (candidate.errors.length) return refuse('generated YAML failed syntax validation');
321
+ const cmd = safeCommand(toolsets);
322
+ const expected = {
323
+ ...before,
324
+ mcp_servers: {
325
+ ...(before.mcp_servers || {}),
326
+ [SAFE_SERVER]: { command: cmd.command, args: cmd.args, env: { CLOUDRU_VM_BIN: enginePath }, enabled: true, timeout: longRunningProfile(toolsets) ? 1200 : 120, tools: { include: selectedToolNames(toolsets) } },
327
+ },
328
+ };
329
+ if (!isDeepStrictEqual(candidate.toJS(), expected)) return refuse('generated YAML would change other configuration');
330
+ } catch { return refuse('generated YAML could not be validated'); }
331
+ fs.mkdirSync(home, { recursive: true, mode: 0o700 });
332
+ const tmp = path.join(home, `.config.yaml.cloudru-hub-${process.pid}.tmp`);
333
+ try {
334
+ fs.writeFileSync(tmp, updated, { flag: 'wx', mode: fs.existsSync(file) ? fs.statSync(file).mode & 0o777 : 0o600 });
335
+ fs.renameSync(tmp, file);
336
+ } finally {
337
+ if (fs.existsSync(tmp)) fs.unlinkSync(tmp);
338
+ }
339
+ return { code: 0, lines: [`cloudru-hub install: wrote ${file} with ${selectedToolNames(toolsets).length} classified tools and mcp-safe.`, ' Restart Hermes and inspect loaded MCP tools before claiming activation.'] };
340
+ }
341
+
121
342
  /**
122
343
  * Execute the emitted brake against the ADR-004 acid fixtures and REQUIRE the veto.
123
344
  * Returns {ok, results:[{fixture, expected, got}]}. An installer that skips this and
@@ -141,16 +362,23 @@ function probeBrake(hookPath) {
141
362
  return { ok, results };
142
363
  }
143
364
 
144
- /**
145
- * Install into a project dir for a target. Pure I/O orchestration; returns
146
- * {code, lines:[...]} — code 0 success, 2 refused (degraded), 3 plan-only, 1 failure.
147
- */
365
+ /** Install files or print guarded setup instructions; code 0 emitted, 2 refused, 3 manual setup. */
148
366
  function install(target, projectDir, opts = {}) {
149
367
  const lines = [];
150
368
  const tier = TIERS[target];
151
369
  if (!tier) {
152
370
  return { code: 1, lines: [`cloudru-hub install: unknown target "${target}" (known: ${Object.keys(TIERS).join(', ')})`] };
153
371
  }
372
+ const toolsets = opts.toolsets || 'inventory';
373
+ if (target === 'claude-code' && toolsets === 'k8s-deploy') {
374
+ try { selectedToolNames(toolsets); }
375
+ catch (error) { return { code: 1, lines: [`cloudru-hub install: ${error.message}`] }; }
376
+ return installClaudeK8s(projectDir, opts);
377
+ }
378
+ if (['codex', 'opencode', 'pi', 'hermes'].includes(target)) {
379
+ try { selectedToolNames(toolsets); }
380
+ catch (error) { return { code: 1, lines: [`cloudru-hub install: ${error.message}`] }; }
381
+ }
154
382
 
155
383
  if (tier === 'degraded') {
156
384
  return {
@@ -178,20 +406,10 @@ function install(target, projectDir, opts = {}) {
178
406
  };
179
407
  }
180
408
 
181
- if (tier === 'plan-only') {
182
- // codex: strongest non-Claude-Code target, but hooks are user-global-only and a
183
- // non-executed veto is indistinguishable from an absent one (measured, codex.md §3).
184
- return {
185
- code: 3,
186
- lines: [
187
- 'cloudru-hub install: target "codex" — plan printed, NOT auto-applied (ADR-005: delivery requires an EXECUTED veto probe).',
188
- ' 1. codex mcp add cloudru-vm -- cloudru-hub mcp # registers the stdio server in ~/.codex/config.toml',
189
- ` 2. Merge into ~/.codex/hooks.json (user-global ONLY — project hooks.json is a measured silent no-op):`,
190
- ` {"hooks":{"preToolUse":[{"command":"node ${HOOK_TEMPLATE}"}]}}`,
191
- ' 3. MANDATORY probe: in a codex session run `sshpass -p x ssh user1@1.2.3.4` and CONFIRM the block message.',
192
- ' A hook file on disk is NOT evidence (health-advisor 1.2.0 lesson: layout ≠ registration).',
193
- ],
194
- };
409
+ if (tier === 'safe-mcp') {
410
+ if (target === 'codex') return installCodex(projectDir, toolsets);
411
+ if (target === 'hermes') return installHermes(toolsets, opts);
412
+ return installJsonMcp(target, projectDir, toolsets);
195
413
  }
196
414
 
197
415
  // ── claude-code FULL path ──
@@ -248,5 +466,6 @@ module.exports = {
248
466
  mergeHookEvents,
249
467
  claudeSettingsFragment,
250
468
  probeBrake,
469
+ installClaudeK8s,
251
470
  install,
252
471
  };
@@ -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');