@dzhechkov/cloudru-hub 0.1.11 → 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/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "@dzhechkov/cloudru-hub",
3
- "version": "0.1.11",
3
+ "version": "0.1.13",
4
4
  "licenseHold": {
5
5
  "adr": "features/hermes-claude-adaptation/03_adr/001-licence-and-provenance-precondition.md",
6
6
  "note": "SATISFIED hold (ADR-001 AM-2, grant recorded 2026-08-12 — see grant-record-2026-08-12.md). The field deliberately STAYS: the dz guard `licence-hold` rule passes a satisfied hold with the trigger in place and keeps verifying LICENSE (no PENDING placeholder, Grant-Confirmation URL), THIRD_PARTY_NOTICES and the SPDX license field on every publish. test/pack-holds.test.mjs goes RED if this field is dropped or the LICENSE grant record is degraded."
7
7
  },
8
- "description": "Unofficial Cloud.ru Evolution launcher for Claude Code and harness-cli targets: thin JS launcher for the cloudru-vm MCP engine (144 tools), per-target installers with a deterministic paid-step brake (permission ask/deny + PreToolUse veto hooks), and skill dialect compiler. The engine binary is resolved at RUNTIME, never bundled. NOT affiliated with Cloud.ru.",
8
+ "description": "Unofficial Cloud.ru Evolution launcher with a pinned cloud-write-blocking MCP proxy for Codex, Hermes, OpenCode and Pi, Claude Code permissions, and skill dialect compiler. Engine binary resolved at runtime, never bundled. NOT affiliated with Cloud.ru.",
9
9
  "main": "src/cli.js",
10
10
  "bin": {
11
11
  "cloudru-hub": "./bin/cloudru-hub.js"
@@ -26,9 +26,9 @@
26
26
  ],
27
27
  "cloudruHub": {
28
28
  "binaryHashes": {
29
- "linux-x64": "59f83fc0678b95146ca539764d0c805bd2514b8588f198c1e835b26e54c05124"
29
+ "linux-x64": "89c5073418927ee71ac1de381eaba5ab69d599d615edc51b868d876e712dbf10"
30
30
  },
31
- "engineVersion": "cloudru-vm 0.2.3-20260718",
31
+ "engineVersion": "cloudru-vm 0.2.6-20261020",
32
32
  "platformPackages": {
33
33
  "linux-x64": "@dzhechkov/cloudru-vm-linux-x64"
34
34
  }
@@ -49,19 +49,22 @@
49
49
  "engines": {
50
50
  "node": ">=18.0.0"
51
51
  },
52
+ "dependencies": {
53
+ "yaml": "^2.9.0"
54
+ },
55
+ "scripts": {
56
+ "test": "node --test 'test/*.test.mjs'"
57
+ },
52
58
  "repository": {
53
59
  "type": "git",
54
60
  "url": "git+https://github.com/djd1m/dz-harness.git",
55
61
  "directory": "packages/@dzhechkov/cloudru-hub"
56
62
  },
57
- "homepage": "https://github.com/djd1m/dz-harness/tree/main/packages/@dzhechkov/cloudru-hub#readme",
63
+ "homepage": "https://aicoding.space",
58
64
  "bugs": {
59
65
  "url": "https://github.com/djd1m/dz-harness/issues"
60
66
  },
61
67
  "publishConfig": {
62
68
  "access": "public"
63
- },
64
- "scripts": {
65
- "test": "node --test 'test/*.test.mjs'"
66
69
  }
67
- }
70
+ }
package/sbom.json CHANGED
@@ -15,7 +15,7 @@
15
15
  "hashes": [
16
16
  {
17
17
  "alg": "SHA-256",
18
- "content": "edc0f1b076a22dc14b863bef8b287c1b9d8618a12b7ecf9f6eb0c497f74361f3"
18
+ "content": "d2be06e900ab3d02d367ff5d91129ef3faa9a16e1afac6fba8ae9cec4a51c322"
19
19
  }
20
20
  ]
21
21
  },
@@ -35,7 +35,7 @@
35
35
  "hashes": [
36
36
  {
37
37
  "alg": "SHA-256",
38
- "content": "504ce5cd5b6293b3606f861f31406ae89aa6fee45a03c9099d793aa5a456ced1"
38
+ "content": "c505a9bb7ab6f8d9fbe1cac64e22b12ec14f674f979af7120457b8ba49039484"
39
39
  }
40
40
  ]
41
41
  },
@@ -85,7 +85,7 @@
85
85
  "hashes": [
86
86
  {
87
87
  "alg": "SHA-256",
88
- "content": "13d6a3e87d767d0015f6b2af0223633fa99e49c69c9a8adcec8f6fce6e46d36e"
88
+ "content": "b1216e31ed28655ea99d9ef06d82d4d35d5499bad1ecadc8e7051e8c74bd7c97"
89
89
  }
90
90
  ]
91
91
  },
@@ -95,7 +95,17 @@
95
95
  "hashes": [
96
96
  {
97
97
  "alg": "SHA-256",
98
- "content": "56221fa8170f2b32ab6d3a907261badc07695f47ae9ac4adca4e43d2ea5ee06f"
98
+ "content": "c9f53d02b7ee06791eac22214ca91e999aeec2ee4dbd53b6a27dc46d4e5bcbc2"
99
+ }
100
+ ]
101
+ },
102
+ {
103
+ "type": "file",
104
+ "name": "docs/HERMES-CODEX.md",
105
+ "hashes": [
106
+ {
107
+ "alg": "SHA-256",
108
+ "content": "08e1e83985c31b51aa053811b1b6a76fb1960323c5ccb8ae1d5fdd8b91b8df11"
99
109
  }
100
110
  ]
101
111
  },
@@ -105,7 +115,7 @@
105
115
  "hashes": [
106
116
  {
107
117
  "alg": "SHA-256",
108
- "content": "092142c00295c54866330f2159a13284d04e2f35a749b32dff0ca4f2ab28ea54"
118
+ "content": "f043a370b9f23b2efc6c64702ab358ee168b6c9cd1b5d9efe4a9da287274e42c"
109
119
  }
110
120
  ]
111
121
  },
@@ -119,7 +129,7 @@
119
129
  },
120
130
  {
121
131
  "name": "dz:canonical-json-sha256-v2",
122
- "value": "9586c2e63291e19031382f57b2f81d9a0f7d2917fc026e1e704f94b9108b5931"
132
+ "value": "671ebb618e9d0a939a047433791cd9a34e502339596d11a697b50fb32a0694ec"
123
133
  }
124
134
  ]
125
135
  },
@@ -139,7 +149,27 @@
139
149
  "hashes": [
140
150
  {
141
151
  "alg": "SHA-256",
142
- "content": "db981ad0078c80ba7a859057638a2576d7f02d87ca2891f8083a92a8147742dd"
152
+ "content": "af553e9261300eb46240212eb03b01ccc17234fed1a9f1587d5784f3bfacb5f4"
153
+ }
154
+ ]
155
+ },
156
+ {
157
+ "type": "file",
158
+ "name": "src/credentials.js",
159
+ "hashes": [
160
+ {
161
+ "alg": "SHA-256",
162
+ "content": "21f074b4bf5b5ee9e893a2c1919cf4bb62f9fa05a0d7a1a8c04afc051ccb0e78"
163
+ }
164
+ ]
165
+ },
166
+ {
167
+ "type": "file",
168
+ "name": "src/deploy-approval.js",
169
+ "hashes": [
170
+ {
171
+ "alg": "SHA-256",
172
+ "content": "5c1fba3d298df73ff82f28c72bc5199d23acc6dfc64022438874b0e3381c9394"
143
173
  }
144
174
  ]
145
175
  },
@@ -159,7 +189,27 @@
159
189
  "hashes": [
160
190
  {
161
191
  "alg": "SHA-256",
162
- "content": "5d087b289aaf8cd2e1b4cffbd2227eb3bdc7264cddc0877561e08bc0ea6d6fd2"
192
+ "content": "c928db48febfb4a79884ce1830365a8302122a55fb5d6ada8e771c049396ca89"
193
+ }
194
+ ]
195
+ },
196
+ {
197
+ "type": "file",
198
+ "name": "src/k8s-approval.js",
199
+ "hashes": [
200
+ {
201
+ "alg": "SHA-256",
202
+ "content": "014c2effd1e843d870ae1af4f93f5c82faff9972e9f6ca028ed39d47ec33ee3a"
203
+ }
204
+ ]
205
+ },
206
+ {
207
+ "type": "file",
208
+ "name": "src/probe-child.js",
209
+ "hashes": [
210
+ {
211
+ "alg": "SHA-256",
212
+ "content": "0f7a0c1f6d0d033006ca816df49c24e69eb82aa6b70ec03491bce9e4d674bdd4"
163
213
  }
164
214
  ]
165
215
  },
@@ -173,6 +223,26 @@
173
223
  }
174
224
  ]
175
225
  },
226
+ {
227
+ "type": "file",
228
+ "name": "src/safe-mcp.js",
229
+ "hashes": [
230
+ {
231
+ "alg": "SHA-256",
232
+ "content": "4f95cfb9576c411232503b26053ece6d8d186e4b337beff9bd4afd249f74a014"
233
+ }
234
+ ]
235
+ },
236
+ {
237
+ "type": "file",
238
+ "name": "src/toolsets.js",
239
+ "hashes": [
240
+ {
241
+ "alg": "SHA-256",
242
+ "content": "89229c882b6a3f5bb9ee542b31d1a40374b91879747582a3e82e8aadc72decb3"
243
+ }
244
+ ]
245
+ },
176
246
  {
177
247
  "type": "file",
178
248
  "name": "templates/.claude/hooks/cloudru-ssh-guard.cjs",
@@ -183,13 +253,33 @@
183
253
  }
184
254
  ]
185
255
  },
256
+ {
257
+ "type": "file",
258
+ "name": "test/billing-credentials.test.mjs",
259
+ "hashes": [
260
+ {
261
+ "alg": "SHA-256",
262
+ "content": "f2e1b05bdb6a6e811844ee19b0142daa02647b387f5d96b4a5d29bd34e3c985d"
263
+ }
264
+ ]
265
+ },
186
266
  {
187
267
  "type": "file",
188
268
  "name": "test/classification.test.mjs",
189
269
  "hashes": [
190
270
  {
191
271
  "alg": "SHA-256",
192
- "content": "25290e9a8715d13670d617e23cb47c4c2d71b1506a8d98e5b682733b626a18b8"
272
+ "content": "026e3031180ce9227b4d31775b9c886b10ad0cb418ed0754aed75b12ded84e31"
273
+ }
274
+ ]
275
+ },
276
+ {
277
+ "type": "file",
278
+ "name": "test/deploy-approval.test.mjs",
279
+ "hashes": [
280
+ {
281
+ "alg": "SHA-256",
282
+ "content": "d72e4363fd50d0fcc65fd23b40601516bd73368c3659f2a55a394c4d1ed3f85c"
193
283
  }
194
284
  ]
195
285
  },
@@ -199,7 +289,7 @@
199
289
  "hashes": [
200
290
  {
201
291
  "alg": "SHA-256",
202
- "content": "9b83b2e2cbbee4ead33af61db840833ce4aa2dd0cf5a4448d72d5886098639c6"
292
+ "content": "3e6236f0cfb159dd73477b6243f6af536925968308085fd248df93932a2bebbf"
203
293
  }
204
294
  ]
205
295
  },
@@ -219,7 +309,27 @@
219
309
  "hashes": [
220
310
  {
221
311
  "alg": "SHA-256",
222
- "content": "e986b29346672bf75b384ab6d8657265b8ecb8f62df6563dca317c1fc63fc8fc"
312
+ "content": "07fe729b0dc6955329d0a3121ce97d575582f8c6f2895e72d0c38175f82b52d2"
313
+ }
314
+ ]
315
+ },
316
+ {
317
+ "type": "file",
318
+ "name": "test/k8s-approval.test.mjs",
319
+ "hashes": [
320
+ {
321
+ "alg": "SHA-256",
322
+ "content": "af04d1941fb7aed2cc51b05f65ffed1c440d4d23a7fb26e13261d45e07b0a1f8"
323
+ }
324
+ ]
325
+ },
326
+ {
327
+ "type": "file",
328
+ "name": "test/mutation-registry-freshness.test.mjs",
329
+ "hashes": [
330
+ {
331
+ "alg": "SHA-256",
332
+ "content": "99cc5dd9f32ffbfbde09361940190f41b2efd682f687b3db67b5a3b943e66f47"
223
333
  }
224
334
  ]
225
335
  },
@@ -229,7 +339,7 @@
229
339
  "hashes": [
230
340
  {
231
341
  "alg": "SHA-256",
232
- "content": "23baf0c1c20afc5bbbfcd72ae9c553ce8a774c25328609e33f43e11cf4ca5581"
342
+ "content": "09b8fdc28bbe5df9b4cfd7d563c1acb479532157bbe1a99e909b3b370dd96630"
233
343
  }
234
344
  ]
235
345
  },
@@ -249,7 +359,17 @@
249
359
  "hashes": [
250
360
  {
251
361
  "alg": "SHA-256",
252
- "content": "6b524f69c5ed03aaafab67e734e58092033423b86feade28ad03f516feba2380"
362
+ "content": "3b695381c2df4f5b592b377bd3ef5b3f3953af9f6f140600cfcddc975563bffc"
363
+ }
364
+ ]
365
+ },
366
+ {
367
+ "type": "file",
368
+ "name": "test/safe-mcp.test.mjs",
369
+ "hashes": [
370
+ {
371
+ "alg": "SHA-256",
372
+ "content": "45406dabeee0635ede5cda3d2f4144c5c13886d624287fd223b01252d2859b87"
253
373
  }
254
374
  ]
255
375
  }
package/src/cli.js CHANGED
@@ -6,6 +6,9 @@ const { spawn, spawnSync } = require('child_process');
6
6
  const { resolveEngine } = require('./resolve');
7
7
  const { loadClassification, loadSnapshot, coverageGaps } = require('./classification');
8
8
  const { install } = require('./install');
9
+ const { runSafeMcp } = require('./safe-mcp');
10
+ const { issueApproval } = require('./deploy-approval');
11
+ const { issueK8sApproval } = require('./k8s-approval');
9
12
  const { compileSkill, writeCompiled } = require('./dialects');
10
13
  const PKG = require('../package.json');
11
14
 
@@ -21,14 +24,23 @@ Usage: cloudru-hub <command> [options]
21
24
  Commands:
22
25
  resolve [--json] Locate the engine binary (env CLOUDRU_VM_BIN → ~/.cloudru-hub/config.json
23
26
  enginePath → optional platform package) and verify its sha256 pin.
24
- self-test [--json] End-to-end local check: resolve → sha256 → engine version → live
25
- tools/list probe → golden-classification coverage. Exit 0 = all green.
27
+ self-test [--json] Local check: resolve → sha256 → engine version → live tools/list
28
+ → golden-classification coverage. Exit 0 = all green.
26
29
  mcp Run the resolved engine as an MCP stdio server (what .mcp.json points at).
27
- install --target <t> Emit the per-target integration per ADR-005 tiering:
28
- claude-code (full: .mcp.json + permission brake + PreToolUse veto, with an
29
- EXECUTED veto probe), codex (plan-only), agents-md|gemini (pointer-only),
30
- openclaude|opencode|cursor|windsurf|copilot (refused: DEGRADED until live probe).
31
- [--dir <path>] Project directory (default: cwd). [--npx] use the npx form in .mcp.json.
30
+ mcp-safe [--toolsets <groups>] Run a pinned MCP proxy; vm-deploy and k8s-deploy enable bounded deployment.
31
+ approve --plan-id <id> --max-monthly-rub <amount> [--site-sha256 <digest>]
32
+ Authorize one exact VM plan for ten minutes within a RUB/month cap.
33
+ Static sites require the artifact digest shown by deploy_plan.
34
+ k8s-approve --cluster-id <uuid> --namespace <name> --manifest <file> --manifest-sha256 <digest>
35
+ Authorize one exact three-resource Snake manifest on an existing cluster.
36
+ install --target <t> claude-code: permissions + executed hook probe;
37
+ codex|opencode|pi: project MCP config using mcp-safe;
38
+ hermes: guarded $HERMES_HOME/config.yaml entry using mcp-safe;
39
+ openclaude|cursor|windsurf|copilot: refused pending live probe.
40
+ [--dir <path>] Project directory (default: cwd). [--toolsets <groups>] defaults to
41
+ inventory for safe hosts; all|compute|kubernetes|data|network|storage|billing|vm-deploy|k8s-deploy.
42
+ [--hermes-home <dir>] overrides $HERMES_HOME for Hermes.
43
+ [--npx] use the npx form in Claude .mcp.json.
32
44
  compile-skill --canonical <dir> --target <t> --out <dir>
33
45
  Generate a target dialect of the canonical skill tree (ADR-006). The
34
46
  canonical corpus is NOT shipped here; point --canonical at the skill/
@@ -53,8 +65,7 @@ function parseArgs(argv) {
53
65
  }
54
66
 
55
67
  function jsonRpcProbe(binPath, timeoutMs = 20_000) {
56
- const req = JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list' }) + '\n';
57
- const r = spawnSync(binPath, ['mcp'], { input: req, encoding: 'utf8', timeout: timeoutMs, maxBuffer: 16 * 1024 * 1024 });
68
+ const r = spawnSync(process.execPath, [require('path').join(__dirname, 'probe-child.js'), binPath], { encoding: 'utf8', timeout: timeoutMs, maxBuffer: 16 * 1024 * 1024 });
58
69
  if (r.error) return { error: String(r.error.message || r.error) };
59
70
  const line = String(r.stdout || '').split('\n').find((l) => l.trim().startsWith('{'));
60
71
  if (!line) return { error: 'engine produced no JSON-RPC response line' };
@@ -76,7 +87,7 @@ function cmdResolve(flags, write) {
76
87
  write(`source ${r.source}`);
77
88
  write(`sha256 ${r.sha256}`);
78
89
  write(`pinned ${r.pinned || '<no pin for ' + r.key + '>'}`);
79
- write(`verified ${r.verified ? 'yes — matches the pinned baseline' : 'NO — not the pinned baseline (tolerated for env/config paths)'}`);
90
+ write(`verified ${r.verified ? 'yes — matches the pinned baseline' : 'NO — not the pinned baseline (resolution only; MCP launch refused)'}`);
80
91
  return 0;
81
92
  }
82
93
 
@@ -87,7 +98,11 @@ function cmdSelfTest(flags, write) {
87
98
  const r = resolveEngine();
88
99
  if (r.error) { step('resolve', false, r.error.split('\n')[0]); if (flags.has('json')) write(JSON.stringify(out, null, 2)); return 1; }
89
100
  step('resolve', true, `${r.path} (source: ${r.source})`);
90
- step('sha256', true, `${r.sha256}${r.verified ? ' — MATCHES pinned baseline' : ' — no pin match (dev build?)'}`);
101
+ step('sha256', r.verified, `${r.sha256}${r.verified ? ' — MATCHES pinned baseline' : ' — DOES NOT MATCH pinned baseline'}`);
102
+ if (!r.verified) {
103
+ if (flags.has('json')) write(JSON.stringify(out, null, 2));
104
+ return 1;
105
+ }
91
106
 
92
107
  const ver = spawnSync(r.path, ['version'], { encoding: 'utf8', timeout: 10_000 });
93
108
  const verOk = ver.status === 0;
@@ -97,13 +112,13 @@ function cmdSelfTest(flags, write) {
97
112
  if (probe.error) { step('tools-list', false, probe.error); if (flags.has('json')) write(JSON.stringify(out, null, 2)); return 1; }
98
113
  const names = probe.tools.map((t) => t.name);
99
114
  const snap = loadSnapshot();
100
- step('tools-list', true, `${names.length} tools live (snapshot: ${snap.toolCount})`);
115
+ step('tools-list', names.length === snap.toolCount, `${names.length} tools live (snapshot: ${snap.toolCount})`);
101
116
 
102
117
  const gaps = coverageGaps(names);
103
- const gapsOk = gaps.unknown.length === 0;
118
+ const gapsOk = gaps.unknown.length === 0 && gaps.missing.length === 0;
104
119
  step('classification-coverage', gapsOk, gapsOk
105
120
  ? `every live tool is classified (${Object.keys(loadClassification().tools).length} golden entries)${gaps.missing.length ? `; ${gaps.missing.length} classified tool(s) absent live: ${gaps.missing.join(', ')}` : ''}`
106
- : `UNCLASSIFIED live tools (deny-by-default — update data/tools-classification.json): ${gaps.unknown.join(', ')}`);
121
+ : `tool drift: unclassified=${gaps.unknown.join(', ') || '<none>'}; missing=${gaps.missing.join(', ') || '<none>'}`);
107
122
 
108
123
  out.ok = out.steps.every((s) => s.ok);
109
124
  if (flags.has('json')) write(JSON.stringify(out, null, 2));
@@ -114,6 +129,10 @@ function cmdSelfTest(flags, write) {
114
129
  function cmdMcp() {
115
130
  const r = resolveEngine();
116
131
  if (r.error) { process.stderr.write(r.error + '\n'); return 1; }
132
+ if (!r.verified) {
133
+ process.stderr.write(`cloudru-hub mcp: engine sha256 ${r.sha256} does not match pinned ${r.pinned || '<none>'}; refusing to launch\n`);
134
+ return 1;
135
+ }
117
136
  const child = spawn(r.path, ['mcp'], { stdio: 'inherit' });
118
137
  child.on('exit', (code, signal) => process.exit(signal ? 1 : code === null ? 1 : code));
119
138
  child.on('error', (e) => { process.stderr.write('cloudru-hub mcp: ' + e.message + '\n'); process.exit(1); });
@@ -139,10 +158,40 @@ function main(argv, write = (s) => process.stdout.write(s + '\n')) {
139
158
  const rc = cmdMcp();
140
159
  return rc === null ? null : rc;
141
160
  }
161
+ case 'mcp-safe':
162
+ return runSafeMcp({ toolsets: opts.get('toolsets') || 'all' });
163
+ case 'approve': {
164
+ const amount = Number(opts.get('max-monthly-rub'));
165
+ if (!opts.get('plan-id') || !opts.has('max-monthly-rub') || !Number.isFinite(amount) || amount < 0) {
166
+ write('cloudru-hub approve: --plan-id and non-negative --max-monthly-rub are required');
167
+ return 1;
168
+ }
169
+ try {
170
+ const approval = issueApproval(opts.get('plan-id'), amount, process.env, Date.now(), opts.get('site-sha256') || '');
171
+ write(flags.has('json') ? JSON.stringify(approval, null, 2) :
172
+ `approved ${approval.planId}: ${approval.vmName}, estimated ${approval.estimatedMonthlyRub} RUB/month (cap ${approval.maxMonthlyRub}); expires ${new Date(approval.expiresAt).toISOString()}${approval.staticSite ? `; static source ${approval.staticSite.source}, SHA-256 ${approval.staticSite.sha256} (${approval.staticSite.sizeBytes} bytes, ${approval.staticSite.files.length} files), ${approval.staticSite.ingress}; ${approval.staticSite.estimateScope}` : ''}`);
173
+ return 0;
174
+ } catch (error) {
175
+ write(`cloudru-hub approve: ${error.message}`);
176
+ return 1;
177
+ }
178
+ }
179
+ case 'k8s-approve': {
180
+ try {
181
+ const approval = issueK8sApproval(opts.get('cluster-id'), opts.get('namespace'),
182
+ opts.get('manifest'), opts.get('manifest-sha256'));
183
+ write(flags.has('json') ? JSON.stringify(approval, null, 2) :
184
+ `approved ${approval.approvalId}: cluster ${approval.clusterId}, namespace ${approval.namespace}, SHA-256 ${approval.manifestSha256}; expires ${new Date(approval.expiresAt).toISOString()}`);
185
+ return 0;
186
+ } catch (error) {
187
+ write(`cloudru-hub k8s-approve: ${error.message}`);
188
+ return 1;
189
+ }
190
+ }
142
191
  case 'install': {
143
192
  const target = opts.get('target');
144
193
  if (!target) { write('cloudru-hub install: --target is required (claude-code|codex|agents-md|gemini|…)'); return 1; }
145
- const res = install(target, opts.get('dir') || process.cwd(), { npx: flags.has('npx') });
194
+ const res = install(target, opts.get('dir') || process.cwd(), { npx: flags.has('npx'), toolsets: opts.get('toolsets') || 'inventory', hermesHome: opts.get('hermes-home') });
146
195
  for (const line of res.lines) write(line);
147
196
  return res.code;
148
197
  }
@@ -0,0 +1,61 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const os = require('os');
5
+ const path = require('path');
6
+ const UUID = /^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/i;
7
+
8
+ const ZONES = Object.freeze({
9
+ 'ru.AZ-1': '7c99a597-8516-494f-a2c7-d7377048681e',
10
+ 'ru.AZ-2': '479a4ab3-3ff3-4972-95c5-7610bac5c0bb',
11
+ 'ru.AZ-3': '2c63c482-2532-4bba-8c9b-70ea330507bf',
12
+ });
13
+
14
+ function credentialsPath(env = process.env) {
15
+ return env.CLOUDRU_HUB_CREDENTIALS || path.join(os.homedir(), '.config', 'cloudru-hub', 'credentials.json');
16
+ }
17
+
18
+ function readCredentials(env = process.env) {
19
+ const file = credentialsPath(env);
20
+ const st = fs.lstatSync(file);
21
+ if (!st.isFile() || st.isSymbolicLink()) throw new Error('credentials must be a regular file');
22
+ if ((st.mode & 0o077) !== 0) throw new Error('credentials permissions must be 0600');
23
+ if (typeof process.getuid === 'function' && st.uid !== process.getuid()) throw new Error('credentials owner differs from MCP process');
24
+ if (st.size > 65536) throw new Error('credentials file is too large');
25
+ let parsed;
26
+ try { parsed = JSON.parse(fs.readFileSync(file, 'utf8')); }
27
+ catch { throw new Error('credentials file is not valid JSON'); }
28
+ if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object') throw new Error('credentials JSON must be an object');
29
+ for (const key of ['key_id', 'secret', 'project_id']) {
30
+ if (typeof parsed[key] !== 'string' || !parsed[key].trim()) throw new Error(`credentials ${key} is required`);
31
+ if (/[\r\n\0]/.test(parsed[key])) throw new Error(`credentials ${key} contains a control character`);
32
+ }
33
+ if (parsed.agreement_id !== undefined &&
34
+ (typeof parsed.agreement_id !== 'string' || !parsed.agreement_id.trim() || /[\r\n\0]/.test(parsed.agreement_id))) {
35
+ throw new Error('credentials agreement_id must be a non-empty string without control characters');
36
+ }
37
+ if (!UUID.test(parsed.project_id)) throw new Error('credentials project_id must be a UUID');
38
+ const region = parsed.region || 'ru.AZ-2';
39
+ const zone = ZONES[region] || region;
40
+ if (!UUID.test(zone)) throw new Error('credentials region must be a known zone or UUID');
41
+ return {
42
+ path: file,
43
+ projectId: parsed.project_id,
44
+ env: {
45
+ CLOUDRU_KEY_ID: parsed.key_id,
46
+ CLOUDRU_SECRET: parsed.secret,
47
+ CLOUDRU_PROJECT_ID: parsed.project_id,
48
+ CLOUDRU_REGION: zone,
49
+ ...(parsed.agreement_id === undefined ? {} : { CLOUDRU_AGREEMENT_ID: parsed.agreement_id.trim() }),
50
+ },
51
+ };
52
+ }
53
+
54
+ function readOptionalCredentials(env = process.env) {
55
+ // An explicit path must fail loudly when unreadable. Without one, retain
56
+ // environment-only billing setups that have no default credential file.
57
+ if (!env.CLOUDRU_HUB_CREDENTIALS && !fs.existsSync(credentialsPath(env))) return null;
58
+ return readCredentials(env);
59
+ }
60
+
61
+ module.exports = { credentialsPath, readCredentials, readOptionalCredentials, ZONES, UUID };
@@ -0,0 +1,120 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const os = require('os');
5
+ const path = require('path');
6
+ const crypto = require('crypto');
7
+ const { readCredentials, UUID } = require('./credentials');
8
+
9
+ const PLAN_ID = /^plan-[0-9a-f]{12}$/;
10
+
11
+ function stateRoot(env = process.env) { return env.CLOUDRU_VM_HOME || os.homedir(); }
12
+ function approvalRoot(env = process.env) { return env.CLOUDRU_HUB_APPROVALS || path.join(os.homedir(), '.config', 'cloudru-hub', 'approvals'); }
13
+ function planPath(id, env = process.env) {
14
+ if (!PLAN_ID.test(id)) throw new Error('invalid plan_id');
15
+ return path.join(stateRoot(env), '.cloudru-vm', 'plans', `${id}.json`);
16
+ }
17
+ function readPlan(id, env = process.env, now = Date.now()) {
18
+ const file = planPath(id, env);
19
+ const st = fs.lstatSync(file);
20
+ if (!st.isFile() || st.isSymbolicLink() || st.size > 65536) throw new Error('plan file is not a bounded regular file');
21
+ const raw = fs.readFileSync(file);
22
+ const plan = JSON.parse(raw);
23
+ if (plan.plan_id !== id || !UUID.test(plan.project_id || '')) throw new Error('plan identity or project is invalid');
24
+ const expires = Date.parse(plan.expires_at);
25
+ if (!Number.isFinite(expires) || expires <= now) throw new Error('plan expired');
26
+ const price = plan.cost && plan.cost.total_monthly_rub;
27
+ if (!plan.cost || plan.cost.currency !== 'RUB' || !Number.isFinite(price) || price < 0) throw new Error('plan cost is not a finite RUB estimate');
28
+ if (typeof plan.vm?.name !== 'string' || !plan.vm.name) throw new Error('plan VM name is invalid');
29
+ if (!plan.static_site && (typeof plan.ssh?.login !== 'string' || !plan.ssh.login)) throw new Error('plan SSH login is invalid');
30
+ if (plan.static_site) {
31
+ const site = plan.static_site;
32
+ if (!/^[0-9a-f]{64}$/.test(site.sha256 || '') || !Number.isSafeInteger(site.size_bytes) || site.size_bytes < 1 || site.size_bytes > 20 * 1024 ||
33
+ typeof site.source_base64 !== 'string' || site.source_base64.length > 28 * 1024 ||
34
+ !Array.isArray(site.files) || site.files.length < 1 || site.files.length > 32 ||
35
+ !Array.isArray(plan.ports) || plan.ports.length !== 1 || plan.ports[0] !== 80 || plan.network?.floating_ip !== true ||
36
+ typeof plan.network?.attach_fip_id !== 'string' || !UUID.test(plan.network.attach_fip_id)) {
37
+ throw new Error('static-site plan artifact or network scope is invalid');
38
+ }
39
+ }
40
+ return { plan, hash: crypto.createHash('sha256').update(raw).digest('hex'), price, expires };
41
+ }
42
+ function approvalPath(id, env = process.env) { return path.join(approvalRoot(env), `${id}.json`); }
43
+ function ensurePrivateDir(dir) {
44
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
45
+ const st = fs.lstatSync(dir);
46
+ if (!st.isDirectory() || st.isSymbolicLink() || (st.mode & 0o077) !== 0) throw new Error('approval directory must be private (0700)');
47
+ }
48
+ function readPrivateJson(file) {
49
+ const st = fs.lstatSync(file);
50
+ if (!st.isFile() || st.isSymbolicLink() || (st.mode & 0o077) !== 0 || st.size > 65536 ||
51
+ (typeof process.getuid === 'function' && st.uid !== process.getuid())) throw new Error('approval file must be private, owned and bounded');
52
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
53
+ }
54
+
55
+ function issueApproval(id, maxMonthlyRub, env = process.env, now = Date.now(), reviewedSiteSha256 = '') {
56
+ const { plan, hash, price, expires } = readPlan(id, env, now);
57
+ if (plan.static_site && reviewedSiteSha256 !== plan.static_site.sha256) {
58
+ throw new Error('static-site approval requires --site-sha256 matching the reviewed artifact');
59
+ }
60
+ const credentials = readCredentials(env);
61
+ if (credentials.projectId !== plan.project_id) throw new Error('plan project differs from credentials project');
62
+ if (!Number.isFinite(maxMonthlyRub) || maxMonthlyRub < price) throw new Error('monthly cost exceeds operator cap');
63
+ const root = approvalRoot(env);
64
+ ensurePrivateDir(root);
65
+ const record = {
66
+ planId: id,
67
+ planHash: hash,
68
+ projectId: plan.project_id,
69
+ vmName: plan.vm.name,
70
+ sshUser: plan.ssh?.login || '',
71
+ siteMode: !!plan.static_site,
72
+ maxMonthlyRub,
73
+ issuedAt: now,
74
+ expiresAt: Math.min(expires, now + 10 * 60_000),
75
+ };
76
+ fs.writeFileSync(approvalPath(id, env), JSON.stringify(record) + '\n', { flag: 'wx', mode: 0o600 });
77
+ return { planId: id, projectId: record.projectId, vmName: record.vmName, estimatedMonthlyRub: price, maxMonthlyRub, expiresAt: record.expiresAt,
78
+ ...(plan.static_site ? { staticSite: { source: plan.static_site.source, sha256: plan.static_site.sha256, sizeBytes: plan.static_site.size_bytes,
79
+ files: plan.static_site.files.map((file) => ({ path: file.path, sha256: file.sha256, sizeBytes: file.size_bytes })),
80
+ ingress: 'dedicated security group: TCP 80 from 0.0.0.0/0', estimateScope: 'compute and disk only; public IP charges excluded' } } : {}) };
81
+ }
82
+
83
+ function consumeApproval(id, env = process.env, now = Date.now()) {
84
+ const { plan, hash, price } = readPlan(id, env, now);
85
+ const credentials = readCredentials(env);
86
+ const file = approvalPath(id, env);
87
+ const a = readPrivateJson(file);
88
+ if (a.planId !== id || a.planHash !== hash || a.projectId !== plan.project_id || a.projectId !== credentials.projectId || a.siteMode !== !!plan.static_site ||
89
+ a.vmName !== plan.vm.name || !Number.isFinite(a.maxMonthlyRub) || price > a.maxMonthlyRub ||
90
+ !Number.isFinite(a.expiresAt) || a.expiresAt <= now) throw new Error('approval does not match current plan, project, cost or time');
91
+ const spent = path.join(approvalRoot(env), `${id}.used-${crypto.randomBytes(8).toString('hex')}.json`);
92
+ fs.renameSync(file, spent); // one caller consumes the pending path; a replay cannot.
93
+ return a;
94
+ }
95
+
96
+ function recordDeployedVM(approval, result, env = process.env, now = Date.now()) {
97
+ const vmId = result && result.vm_id;
98
+ if (!UUID.test(vmId || '') || result.status !== 'running') throw new Error('deploy did not return a running VM UUID');
99
+ const root = approvalRoot(env);
100
+ ensurePrivateDir(root);
101
+ const session = { planId: approval.planId, projectId: approval.projectId, vmId, sshUser: approval.sshUser, expiresAt: now + 2 * 60 * 60_000 };
102
+ fs.writeFileSync(path.join(root, `${approval.planId}.session.json`), JSON.stringify(session) + '\n', { flag: 'wx', mode: 0o600 });
103
+ return session;
104
+ }
105
+
106
+ function approvedVM(requestedId, env = process.env, now = Date.now()) {
107
+ const root = approvalRoot(env);
108
+ if (!fs.existsSync(root)) throw new Error('no approved deployment session');
109
+ ensurePrivateDir(root);
110
+ const sessions = fs.readdirSync(root).filter((name) => /^plan-[0-9a-f]{12}\.session\.json$/.test(name));
111
+ const credentials = readCredentials(env);
112
+ if (requestedId && !UUID.test(requestedId)) throw new Error('vm_exec requires an approved VM UUID');
113
+ const live = sessions.map((name) => readPrivateJson(path.join(root, name)))
114
+ .filter((s) => s.expiresAt > now && s.projectId === credentials.projectId && UUID.test(s.vmId || ''));
115
+ const matching = requestedId ? live.filter((s) => s.vmId === requestedId) : live;
116
+ if (matching.length !== 1) throw new Error('vm_exec requires exactly one matching active approved VM session');
117
+ return matching[0];
118
+ }
119
+
120
+ module.exports = { stateRoot, approvalRoot, planPath, readPlan, issueApproval, consumeApproval, recordDeployedVM, approvedVM };