@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/.dz-manifest.json +58 -14
- package/CHANGELOG.md +78 -0
- package/README.md +43 -22
- package/bin/cloudru-hub.js +0 -0
- package/data/tools-classification.json +9 -9
- package/data/tools-list-snapshot.json +6 -6
- package/docs/HERMES-CODEX.md +34 -0
- package/docs/LOCAL-TESTING.md +14 -22
- package/package.json +12 -9
- package/sbom.json +123 -13
- package/src/cli.js +64 -15
- package/src/credentials.js +61 -0
- package/src/deploy-approval.js +120 -0
- package/src/install.js +246 -27
- package/src/k8s-approval.js +208 -0
- package/src/probe-child.js +38 -0
- package/src/safe-mcp.js +268 -0
- package/src/toolsets.js +58 -0
- package/templates/.claude/hooks/cloudru-ssh-guard.cjs +0 -0
- package/test/billing-credentials.test.mjs +113 -0
- package/test/classification.test.mjs +23 -32
- package/test/deploy-approval.test.mjs +154 -0
- package/test/dialects.test.mjs +11 -1
- package/test/install.test.mjs +235 -11
- package/test/k8s-approval.test.mjs +125 -0
- package/test/mutation-registry.json +6 -6
- package/test/resolve.test.mjs +46 -4
- package/test/safe-mcp.test.mjs +162 -0
|
@@ -0,0 +1,154 @@
|
|
|
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 { issueApproval } = require('../src/deploy-approval.js');
|
|
12
|
+
const cls = require('../data/tools-classification.json');
|
|
13
|
+
const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'cloudru-deploy-guard-'));
|
|
14
|
+
after(() => fs.rmSync(scratch, { recursive: true, force: true }));
|
|
15
|
+
const projectId = '11111111-2222-3333-4444-555555555555';
|
|
16
|
+
const vmId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee';
|
|
17
|
+
const otherVM = 'bbbbbbbb-cccc-dddd-eeee-ffffffffffff';
|
|
18
|
+
const planId = 'plan-123456abcdef';
|
|
19
|
+
const file = path.join(scratch, 'credentials.json');
|
|
20
|
+
const planFile = path.join(scratch, '.cloudru-vm', 'plans', `${planId}.json`);
|
|
21
|
+
const log = path.join(scratch, 'forwarded.jsonl');
|
|
22
|
+
const fake = path.join(scratch, 'fake-engine');
|
|
23
|
+
const env = { ...process.env, CLOUDRU_HUB_CREDENTIALS: file, CLOUDRU_HUB_APPROVALS: path.join(scratch, 'approvals'), CLOUDRU_VM_HOME: scratch, FAKE_LOG: log };
|
|
24
|
+
fs.writeFileSync(file, JSON.stringify({ key_id: 'test-key', secret: 'test-secret', project_id: projectId, region: 'ru.AZ-2' }), { mode: 0o600 });
|
|
25
|
+
fs.mkdirSync(path.dirname(planFile), { recursive: true });
|
|
26
|
+
const plan = { plan_id: planId, project_id: projectId, expires_at: new Date(Date.now() + 1800000).toISOString(), vm: { name: 'snake-pilot' }, ssh: { login: 'user1' }, cost: { currency: 'RUB', total_monthly_rub: 42 } };
|
|
27
|
+
const savePlan = () => fs.writeFileSync(planFile, JSON.stringify(plan));
|
|
28
|
+
savePlan();
|
|
29
|
+
fs.writeFileSync(fake, `#!/usr/bin/env node
|
|
30
|
+
const fs=require('fs');let b='';process.stdin.setEncoding('utf8');process.stdin.on('data',x=>{b+=x;let i;while((i=b.indexOf('\\n'))>=0){const line=b.slice(0,i);b=b.slice(i+1);if(!line)continue;const m=JSON.parse(line);fs.appendFileSync(process.env.FAKE_LOG,JSON.stringify(m)+'\\n');if(!Object.hasOwn(m,'id'))continue;let result={};if(m.method==='tools/list')result={tools:[{name:'deploy_apply'},{name:'vm_exec'},{name:'destroy'}]};else if(m.method==='tools/call'){const deployment=process.env.FAKE_DEPLOY_OUTCOME?JSON.parse(process.env.FAKE_DEPLOY_OUTCOME):{vm_id:'${vmId}',status:'running',public_ip:'203.0.113.10',password:'generated-secret'};result={isError:m.params.name==='deploy_apply'&&!!process.env.FAKE_DEPLOY_ERROR,content:[{type:'text',text:m.params.name==='deploy_apply'?JSON.stringify(deployment):JSON.stringify({status:'ok'})}]};}process.stdout.write(JSON.stringify({jsonrpc:'2.0',id:m.id,result})+'\\n');}});
|
|
31
|
+
`);
|
|
32
|
+
fs.chmodSync(fake, 0o755);
|
|
33
|
+
const rpc = (id, name, args = {}) => JSON.stringify({ jsonrpc: '2.0', id, method: 'tools/call', params: { name, arguments: args } });
|
|
34
|
+
function run(lines, overrides = {}) {
|
|
35
|
+
fs.writeFileSync(log, '');
|
|
36
|
+
const script = `const {runSafeMcp}=require(${JSON.stringify(path.resolve(import.meta.dirname, '../src/safe-mcp.js'))});runSafeMcp({toolsets:'vm-deploy',resolved:{path:${JSON.stringify(fake)},verified:true,sha256:${JSON.stringify(cls.engineSha256)}}});`;
|
|
37
|
+
const r = spawnSync(process.execPath, ['-e', script], { input: lines.join('\n') + '\n', encoding: 'utf8', timeout: 5000, env: { ...env, ...overrides } });
|
|
38
|
+
assert.equal(r.status, 0, r.stderr || r.error?.message);
|
|
39
|
+
return { responses: r.stdout.trim().split('\n').map(JSON.parse), forwarded: fs.readFileSync(log, 'utf8').trim().split('\n').filter(Boolean).map(JSON.parse) };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
test('credentials require owner-only regular file and do not interpolate shell text', () => {
|
|
43
|
+
assert.equal(readCredentials(env).projectId, projectId);
|
|
44
|
+
assert.equal(readCredentials(env).env.CLOUDRU_KEY_ID, 'test-key');
|
|
45
|
+
fs.chmodSync(file, 0o644);
|
|
46
|
+
assert.throws(() => readCredentials(env), /0600/);
|
|
47
|
+
fs.chmodSync(file, 0o600);
|
|
48
|
+
fs.writeFileSync(file, '{"secret":THIS_IS_A_FAKE_SECRET_FOR_QE}', { mode: 0o600 });
|
|
49
|
+
assert.throws(() => readCredentials(env), (error) => error.message === 'credentials file is not valid JSON');
|
|
50
|
+
fs.writeFileSync(file, JSON.stringify({ key_id: 'test-key', secret: 'test-secret', project_id: projectId, region: 'ru.AZ-2' }), { mode: 0o600 });
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('cost cap and project must match the persisted plan before approval', () => {
|
|
54
|
+
assert.throws(() => issueApproval(planId, 41, env), /cost exceeds/);
|
|
55
|
+
plan.project_id = otherVM;
|
|
56
|
+
savePlan();
|
|
57
|
+
assert.throws(() => issueApproval(planId, 100, env), /project differs/);
|
|
58
|
+
plan.project_id = projectId;
|
|
59
|
+
savePlan();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('approved plan is single-use; remote commands are confined to its VM', () => {
|
|
63
|
+
issueApproval(planId, 50, env);
|
|
64
|
+
const out = run([
|
|
65
|
+
JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list' }),
|
|
66
|
+
rpc(2, 'deploy_apply', { plan_id: planId, confirmed: false }),
|
|
67
|
+
rpc(3, 'deploy_apply', { plan_id: planId, confirmed: true }),
|
|
68
|
+
]);
|
|
69
|
+
const followup = run([
|
|
70
|
+
rpc(4, 'vm_exec', { vm: otherVM, command: 'id' }),
|
|
71
|
+
rpc(5, 'vm_exec', { vm: vmId, user: 'root', command: 'whoami' }),
|
|
72
|
+
rpc(6, 'destroy', { confirmed: true }),
|
|
73
|
+
]);
|
|
74
|
+
const by = Object.fromEntries([...out.responses, ...followup.responses].map((x) => [x.id, x]));
|
|
75
|
+
assert.deepEqual(by[1].result.tools.map((x) => x.name), ['deploy_apply', 'vm_exec']);
|
|
76
|
+
assert.equal(by[2].result.isError, false);
|
|
77
|
+
assert.doesNotMatch(JSON.stringify(by[2]), /generated-secret/);
|
|
78
|
+
for (const id of [3, 4, 6]) assert.ok(by[id].error, `request ${id} blocked`);
|
|
79
|
+
assert.equal(by[5].result.content[0].text, JSON.stringify({ status: 'ok' }));
|
|
80
|
+
const calls = [...out.forwarded, ...followup.forwarded].filter((x) => x.method === 'tools/call');
|
|
81
|
+
assert.deepEqual(calls.map((x) => x.params), [
|
|
82
|
+
{ name: 'deploy_apply', arguments: { plan_id: planId, confirmed: true } },
|
|
83
|
+
{ name: 'vm_exec', arguments: { vm: vmId, user: 'user1', command: 'whoami' } },
|
|
84
|
+
]);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('a changed plan invalidates the exact approval', () => {
|
|
88
|
+
const second = 'plan-abcdef123456';
|
|
89
|
+
const secondFile = path.join(path.dirname(planFile), `${second}.json`);
|
|
90
|
+
fs.writeFileSync(secondFile, JSON.stringify({ ...plan, plan_id: second }));
|
|
91
|
+
issueApproval(second, 50, env);
|
|
92
|
+
fs.writeFileSync(secondFile, JSON.stringify({ ...plan, plan_id: second, cost: { currency: 'RUB', total_monthly_rub: 49 } }));
|
|
93
|
+
const out = run([rpc(1, 'deploy_apply', { plan_id: second, confirmed: true })]);
|
|
94
|
+
assert.match(out.responses[0].error.message, /approval does not match/);
|
|
95
|
+
assert.equal(out.forwarded.length, 0);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('partial deploy preserves its VM UUID for recovery and redacts nested secrets', () => {
|
|
99
|
+
const third = 'plan-123456fedcba';
|
|
100
|
+
fs.writeFileSync(path.join(path.dirname(planFile), `${third}.json`), JSON.stringify({ ...plan, plan_id: third }));
|
|
101
|
+
issueApproval(third, 50, env);
|
|
102
|
+
const outcome = { status: 'state_persistence_failed', result: { vm_id: otherVM, status: 'running', password: 'generated-secret' }, error: 'local state write failed' };
|
|
103
|
+
const out = run([rpc(1, 'deploy_apply', { plan_id: third })], { FAKE_DEPLOY_OUTCOME: JSON.stringify(outcome), FAKE_DEPLOY_ERROR: '1' });
|
|
104
|
+
assert.equal(out.responses[0].result.isError, true);
|
|
105
|
+
const returned = JSON.parse(out.responses[0].result.content[0].text);
|
|
106
|
+
assert.equal(returned.result.vm_id, otherVM);
|
|
107
|
+
assert.equal(returned.result.password, undefined);
|
|
108
|
+
assert.match(returned.recovery, /Inspect/);
|
|
109
|
+
const denied = run([rpc(2, 'vm_exec', { vm: otherVM, command: 'id' })]);
|
|
110
|
+
assert.ok(denied.responses[0].error);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('static site requires the reviewed digest and never grants an SSH command session', () => {
|
|
114
|
+
const id = 'plan-aabbccddeeff';
|
|
115
|
+
const digest = 'a'.repeat(64);
|
|
116
|
+
const sitePlan = { ...plan, plan_id: id, ports: [80], network: { floating_ip: true, subnet_id: 'subnet-a', attach_fip_id: otherVM },
|
|
117
|
+
static_site: { source: 'fixtures/snake/index.html', sha256: digest, size_bytes: 12, source_base64: Buffer.from('hello snake!').toString('base64'),
|
|
118
|
+
files: [{ path: 'index.html', sha256: digest, size_bytes: 12, content_base64: Buffer.from('hello snake!').toString('base64') }] } };
|
|
119
|
+
const siteFile = path.join(path.dirname(planFile), `${id}.json`);
|
|
120
|
+
fs.writeFileSync(siteFile, JSON.stringify(sitePlan));
|
|
121
|
+
assert.throws(() => issueApproval(id, 50, env), /site-sha256/);
|
|
122
|
+
assert.throws(() => issueApproval(id, 50, env, Date.now(), 'b'.repeat(64)), /site-sha256/);
|
|
123
|
+
const approved = issueApproval(id, 50, env, Date.now(), digest);
|
|
124
|
+
assert.equal(approved.staticSite.sha256, digest);
|
|
125
|
+
assert.match(approved.staticSite.ingress, /dedicated security group/);
|
|
126
|
+
assert.match(approved.staticSite.estimateScope, /public IP charges excluded/);
|
|
127
|
+
const outcome = { vm_id: otherVM, status: 'running', public_ip: '203.0.113.10', http_verification: { verified: true } };
|
|
128
|
+
const out = run([rpc(1, 'deploy_apply', { plan_id: id })], { FAKE_DEPLOY_OUTCOME: JSON.stringify(outcome) });
|
|
129
|
+
assert.equal(out.responses[0].result.isError, false);
|
|
130
|
+
const denied = run([rpc(2, 'vm_exec', { vm: otherVM, command: 'id' })]);
|
|
131
|
+
assert.ok(denied.responses[0].error);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test('static-site artifact and same-price network changes invalidate approval', () => {
|
|
135
|
+
const site = { source: 'snake.zip', sha256: 'c'.repeat(64), size_bytes: 20, source_base64: Buffer.from('test').toString('base64'),
|
|
136
|
+
files: [{ path: 'index.html', sha256: 'd'.repeat(64), size_bytes: 10, content_base64: 'aGVsbG8=' }] };
|
|
137
|
+
for (const [suffix, mutation] of [
|
|
138
|
+
['010101010101', (p) => { p.static_site.sha256 = 'e'.repeat(64); }],
|
|
139
|
+
['020202020202', (p) => { p.network.subnet_id = 'subnet-b'; }],
|
|
140
|
+
['030303030303', (p) => { p.vm.zone = { id: 'zone-b', name: 'AZ-2' }; }],
|
|
141
|
+
]) {
|
|
142
|
+
const id = `plan-${suffix}`;
|
|
143
|
+
const original = { ...plan, plan_id: id, ports: [80], network: { floating_ip: true, subnet_id: 'subnet-a', attach_fip_id: otherVM },
|
|
144
|
+
vm: { ...plan.vm, zone: { id: 'zone-a', name: 'AZ-1' } }, static_site: structuredClone(site) };
|
|
145
|
+
const filename = path.join(path.dirname(planFile), `${id}.json`);
|
|
146
|
+
fs.writeFileSync(filename, JSON.stringify(original));
|
|
147
|
+
issueApproval(id, 50, env, Date.now(), site.sha256);
|
|
148
|
+
mutation(original);
|
|
149
|
+
fs.writeFileSync(filename, JSON.stringify(original));
|
|
150
|
+
const out = run([rpc(1, 'deploy_apply', { plan_id: id })]);
|
|
151
|
+
assert.match(out.responses[0].error.message, /approval does not match/);
|
|
152
|
+
assert.equal(out.forwarded.length, 0);
|
|
153
|
+
}
|
|
154
|
+
});
|
package/test/dialects.test.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Skill dialect compiler tests — ADR-006 Confirmation, all layer 1: size, links, dialect
|
|
2
2
|
// grep, determinism. Uses a SYNTHETIC fixture (our own text carrying the Hermes-dialect
|
|
3
3
|
// tokens) — the real canonical corpus is upstream content and never ships in this package.
|
|
4
|
-
import { test } from 'node:test';
|
|
4
|
+
import { after, test } from 'node:test';
|
|
5
5
|
import assert from 'node:assert/strict';
|
|
6
6
|
import fs from 'node:fs';
|
|
7
7
|
import os from 'node:os';
|
|
@@ -11,8 +11,18 @@ import { createRequire } from 'node:module';
|
|
|
11
11
|
const require = createRequire(import.meta.url);
|
|
12
12
|
const { compileSkill, loadDialects, filterFrontmatter } = require('../src/dialects.js');
|
|
13
13
|
|
|
14
|
+
// Every scratch dir is removed at the end (after() + process exit) unless DZ_KEEP_TMP=1 —
|
|
15
|
+
// MEASURED 2026-09-20: 81 cloudru-dialects-* dirs per full sweep (backlog 7cb6f9b7).
|
|
16
|
+
const created = [];
|
|
17
|
+
const removeCreated = () => {
|
|
18
|
+
if (process.env.DZ_KEEP_TMP === '1') return;
|
|
19
|
+
for (const d of created.splice(0)) fs.rmSync(d, { recursive: true, force: true });
|
|
20
|
+
};
|
|
21
|
+
after(removeCreated);
|
|
22
|
+
process.on('exit', removeCreated);
|
|
14
23
|
function fixture() {
|
|
15
24
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cloudru-dialects-'));
|
|
25
|
+
created.push(dir);
|
|
16
26
|
fs.writeFileSync(path.join(dir, 'SKILL.md'), `---
|
|
17
27
|
name: cloudru-hub
|
|
18
28
|
description: Управление Cloud.ru Evolution.
|
package/test/install.test.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Installer tests — ADR-005 tiering + ADR-004 brake emission with EXECUTED veto.
|
|
2
|
-
import { test } from 'node:test';
|
|
2
|
+
import { after, test } from 'node:test';
|
|
3
3
|
import assert from 'node:assert/strict';
|
|
4
4
|
import fs from 'node:fs';
|
|
5
5
|
import os from 'node:os';
|
|
@@ -7,10 +7,21 @@ import path from 'node:path';
|
|
|
7
7
|
import { createRequire } from 'node:module';
|
|
8
8
|
|
|
9
9
|
const require = createRequire(import.meta.url);
|
|
10
|
+
const YAML = require('yaml');
|
|
10
11
|
const { install, mergeSettings, claudeSettingsFragment, probeBrake, HOOK_TEMPLATE, TIERS } = require('../src/install.js');
|
|
11
12
|
const { permissionRules } = require('../src/classification.js');
|
|
13
|
+
const pinnedEngine = { path: '/verified/cloudru-vm', verified: true, sha256: require('../data/tools-classification.json').engineSha256 };
|
|
12
14
|
|
|
13
|
-
|
|
15
|
+
// Every scratch dir is removed at the end (after() + process exit) unless DZ_KEEP_TMP=1 —
|
|
16
|
+
// MEASURED 2026-09-20: 42 cloudru-hub-test-* dirs per full sweep (backlog 7cb6f9b7).
|
|
17
|
+
const created = [];
|
|
18
|
+
const removeCreated = () => {
|
|
19
|
+
if (process.env.DZ_KEEP_TMP === '1') return;
|
|
20
|
+
for (const d of created.splice(0)) fs.rmSync(d, { recursive: true, force: true });
|
|
21
|
+
};
|
|
22
|
+
after(removeCreated);
|
|
23
|
+
process.on('exit', removeCreated);
|
|
24
|
+
const tmp = () => { const d = fs.mkdtempSync(path.join(os.tmpdir(), 'cloudru-hub-test-')); created.push(d); return d; };
|
|
14
25
|
|
|
15
26
|
test('claude-code install emits .mcp.json + permission brake + hook, and the veto is EXECUTED', () => {
|
|
16
27
|
const dir = tmp();
|
|
@@ -80,7 +91,7 @@ test('install is idempotent — second run does not duplicate rules or hooks', (
|
|
|
80
91
|
});
|
|
81
92
|
|
|
82
93
|
test('DEGRADED targets are refused with the ADR-005 reason — never a fake install', () => {
|
|
83
|
-
for (const target of ['openclaude', '
|
|
94
|
+
for (const target of ['openclaude', 'cursor', 'windsurf', 'copilot']) {
|
|
84
95
|
const dir = tmp();
|
|
85
96
|
const res = install(target, dir);
|
|
86
97
|
assert.equal(res.code, 2, target);
|
|
@@ -104,22 +115,235 @@ test('POINTER-ONLY targets get the ≤2048-char pointer with the mandatory no-ve
|
|
|
104
115
|
}
|
|
105
116
|
});
|
|
106
117
|
|
|
107
|
-
test('codex
|
|
118
|
+
test('codex installs a project-scoped safe server and preserves unrelated TOML', () => {
|
|
108
119
|
const dir = tmp();
|
|
120
|
+
fs.mkdirSync(path.join(dir, '.codex'));
|
|
121
|
+
fs.writeFileSync(path.join(dir, '.codex', 'config.toml'), 'model = "other"\n');
|
|
109
122
|
const res = install('codex', dir);
|
|
110
|
-
assert.equal(res.code,
|
|
111
|
-
const out =
|
|
112
|
-
assert.
|
|
113
|
-
assert.
|
|
114
|
-
assert.
|
|
115
|
-
assert.
|
|
123
|
+
assert.equal(res.code, 0, res.lines.join('\n'));
|
|
124
|
+
const out = fs.readFileSync(path.join(dir, '.codex', 'config.toml'), 'utf8');
|
|
125
|
+
assert.match(out, /model = "other"/);
|
|
126
|
+
assert.match(out, /\[mcp_servers\.cloudru_hub\]/);
|
|
127
|
+
assert.match(out, /mcp-safe/);
|
|
128
|
+
assert.match(out, /enabled_tools = .*"vm_list"/);
|
|
129
|
+
assert.match(out, /--toolsets","inventory"/);
|
|
130
|
+
assert.doesNotMatch(out, /"deploy"/);
|
|
131
|
+
assert.match(out, /default_tools_approval_mode = "prompt"/);
|
|
132
|
+
assert.equal(install('codex', dir).code, 0, 'same install is idempotent');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test('OpenCode and Pi install a safe MCP server without replacing unrelated JSON', () => {
|
|
136
|
+
for (const target of ['opencode', 'pi']) {
|
|
137
|
+
const dir = tmp();
|
|
138
|
+
const file = path.join(dir, target === 'pi' ? '.pi/mcp.json' : 'opencode.json');
|
|
139
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
140
|
+
fs.writeFileSync(file, JSON.stringify(target === 'pi' ? { mcpServers: { other: { command: 'test' } }, theme: 'x' } : { mcp: { other: { type: 'remote', url: 'https://example.com/mcp' } }, theme: 'x' }));
|
|
141
|
+
assert.equal(install(target, dir).code, 0);
|
|
142
|
+
const data = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
143
|
+
const servers = target === 'pi' ? data.mcpServers : data.mcp;
|
|
144
|
+
assert.ok(servers.other);
|
|
145
|
+
assert.equal(data.theme, 'x');
|
|
146
|
+
assert.match(JSON.stringify(servers.cloudru_hub), /mcp-safe/);
|
|
147
|
+
assert.equal(install(target, dir).code, 0);
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test('installers refuse a competing raw Cloud.ru server', () => {
|
|
152
|
+
for (const target of ['codex', 'opencode', 'pi']) {
|
|
153
|
+
const dir = tmp();
|
|
154
|
+
if (target === 'codex') {
|
|
155
|
+
fs.mkdirSync(path.join(dir, '.codex'));
|
|
156
|
+
fs.writeFileSync(path.join(dir, '.codex/config.toml'), '[mcp_servers.cloudru-vm]\ncommand = "raw"\n');
|
|
157
|
+
} else {
|
|
158
|
+
const file = path.join(dir, target === 'pi' ? '.pi/mcp.json' : 'opencode.json');
|
|
159
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
160
|
+
fs.writeFileSync(file, JSON.stringify(target === 'pi' ? { mcpServers: { 'cloudru-vm': {} } } : { mcp: { 'cloudru-vm': {} } }));
|
|
161
|
+
}
|
|
162
|
+
assert.equal(install(target, dir).code, 2, target);
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test('Codex refuses quoted and inline TOML MCP declarations before changing the file', () => {
|
|
167
|
+
const variants = [
|
|
168
|
+
'[mcp_servers."cloudru-vm"]\ncommand = "raw"\n',
|
|
169
|
+
'[mcp_servers."cloudru_hub"]\ncommand = "raw"\n',
|
|
170
|
+
'mcp_servers = { cloudru_hub = { command = "raw" } }\n',
|
|
171
|
+
'["\\u006dcp_servers"."cloudru-vm"]\ncommand = "raw"\n',
|
|
172
|
+
];
|
|
173
|
+
for (const source of variants) {
|
|
174
|
+
const dir = tmp();
|
|
175
|
+
const file = path.join(dir, '.codex/config.toml');
|
|
176
|
+
fs.mkdirSync(path.dirname(file));
|
|
177
|
+
fs.writeFileSync(file, source);
|
|
178
|
+
const res = install('codex', dir);
|
|
179
|
+
assert.equal(res.code, 2, source);
|
|
180
|
+
assert.equal(fs.readFileSync(file, 'utf8'), source);
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test('Hermes installs a guarded nonempty include list without changing unrelated YAML', () => {
|
|
185
|
+
const dir = tmp();
|
|
186
|
+
const file = path.join(dir, 'config.yaml');
|
|
187
|
+
const source = 'plugins:\n enabled: true\nmcp_servers:\n github:\n command: "other"\nagent:\n model: "test"\n';
|
|
188
|
+
fs.writeFileSync(file, source);
|
|
189
|
+
const res = install('hermes', dir, { hermesHome: dir, resolvedEngine: pinnedEngine });
|
|
190
|
+
assert.equal(res.code, 0, res.lines.join('\n'));
|
|
191
|
+
const out = fs.readFileSync(file, 'utf8');
|
|
192
|
+
assert.deepEqual(Object.keys(YAML.parse(out).mcp_servers).sort(), ['cloudru_hub', 'github']);
|
|
193
|
+
assert.match(out, /mcp-safe/);
|
|
194
|
+
assert.match(out, /vm_list/);
|
|
195
|
+
assert.match(out, /--toolsets/);
|
|
196
|
+
assert.match(out, /include: \[/);
|
|
197
|
+
assert.equal(YAML.parse(out).mcp_servers.cloudru_hub.env.CLOUDRU_VM_BIN, pinnedEngine.path);
|
|
198
|
+
assert.doesNotMatch(out, /"deploy"/);
|
|
199
|
+
assert.match(out, /plugins:\n enabled: true/);
|
|
200
|
+
assert.match(out, / github:\n command: "other"/);
|
|
201
|
+
assert.match(out, /agent:\n model: "test"/);
|
|
202
|
+
assert.equal(install('hermes', dir, { hermesHome: dir, resolvedEngine: pinnedEngine }).code, 0);
|
|
203
|
+
assert.equal(fs.readFileSync(file, 'utf8'), out, 'second install is idempotent');
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test('Hermes keeps valid comments and refuses a flow-root document', () => {
|
|
207
|
+
const commented = tmp();
|
|
208
|
+
const file = path.join(commented, 'config.yaml');
|
|
209
|
+
fs.writeFileSync(file, 'mcp_servers:\n other:\n command: harmless\n # explanatory comment\n');
|
|
210
|
+
assert.equal(install('hermes', commented, { hermesHome: commented, resolvedEngine: pinnedEngine }).code, 0);
|
|
211
|
+
assert.equal(YAML.parse(fs.readFileSync(file, 'utf8')).mcp_servers.other.command, 'harmless');
|
|
212
|
+
assert.equal(YAML.parse(fs.readFileSync(file, 'utf8')).mcp_servers.cloudru_hub.tools.include.length, 11);
|
|
213
|
+
|
|
214
|
+
const flow = tmp();
|
|
215
|
+
const flowFile = path.join(flow, 'config.yaml');
|
|
216
|
+
fs.writeFileSync(flowFile, '{ model: harmless }\n');
|
|
217
|
+
assert.equal(install('hermes', flow, { hermesHome: flow }).code, 2);
|
|
218
|
+
assert.equal(fs.readFileSync(flowFile, 'utf8'), '{ model: harmless }\n');
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test('Hermes refuses missing or hash-mismatched engine without writing config', () => {
|
|
222
|
+
for (const resolvedEngine of [
|
|
223
|
+
{ error: 'engine absent' },
|
|
224
|
+
{ ...pinnedEngine, sha256: '0'.repeat(64) },
|
|
225
|
+
{ ...pinnedEngine, verified: false },
|
|
226
|
+
]) {
|
|
227
|
+
const dir = tmp();
|
|
228
|
+
const file = path.join(dir, 'config.yaml');
|
|
229
|
+
fs.writeFileSync(file, 'agent:\n model: test\n');
|
|
230
|
+
const res = install('hermes', dir, { hermesHome: dir, resolvedEngine });
|
|
231
|
+
assert.equal(res.code, 2);
|
|
232
|
+
assert.equal(fs.readFileSync(file, 'utf8'), 'agent:\n model: test\n');
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test('Hermes refuses raw Cloud.ru entries and complex YAML without writing', () => {
|
|
237
|
+
for (const source of [
|
|
238
|
+
'mcp_servers:\n cloudru-vm:\n command: raw\n',
|
|
239
|
+
'mcp_servers: {cloudru_hub: {command: raw}}\n',
|
|
240
|
+
'mcp_servers: &servers\n github: {}\n',
|
|
241
|
+
'mcp_servers:\n other:\n command: harmless\n# comment\n cloudru-vm:\n command: raw\n',
|
|
242
|
+
'mcp_servers:\n other:\n command: harmless\n"mcp_servers":\n cloudru-vm:\n command: raw\n',
|
|
243
|
+
]) {
|
|
244
|
+
const dir = tmp();
|
|
245
|
+
const file = path.join(dir, 'config.yaml');
|
|
246
|
+
fs.writeFileSync(file, source);
|
|
247
|
+
assert.equal(install('hermes', dir, { hermesHome: dir }).code, 2, source);
|
|
248
|
+
assert.equal(fs.readFileSync(file, 'utf8'), source);
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test('toolset selection validates before install and limits Codex enabled_tools', () => {
|
|
253
|
+
const dir = tmp();
|
|
254
|
+
const invalid = install('codex', dir, { toolsets: 'inventory,all' });
|
|
255
|
+
assert.equal(invalid.code, 1);
|
|
256
|
+
assert.deepEqual(fs.readdirSync(dir), []);
|
|
257
|
+
const ok = install('codex', dir, { toolsets: 'billing' });
|
|
258
|
+
assert.equal(ok.code, 0);
|
|
259
|
+
const config = fs.readFileSync(path.join(dir, '.codex/config.toml'), 'utf8');
|
|
260
|
+
assert.match(config, /billing_balance/);
|
|
261
|
+
assert.doesNotMatch(config, /vm_list/);
|
|
262
|
+
assert.match(config, /CLOUDRU_HUB_CREDENTIALS/);
|
|
263
|
+
assert.match(config, /CLOUDRU_AGREEMENT_ID/);
|
|
264
|
+
assert.match(config, /CLOUDRU_KEY_ID/);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
test('vm-deploy installs guarded tools and long-call timeouts for Codex, Hermes, OpenCode and Pi', () => {
|
|
268
|
+
const codexDir = tmp();
|
|
269
|
+
assert.equal(install('codex', codexDir, { toolsets: 'vm-deploy' }).code, 0);
|
|
270
|
+
const config = fs.readFileSync(path.join(codexDir, '.codex/config.toml'), 'utf8');
|
|
271
|
+
for (const name of ['deploy_plan', 'deploy_apply', 'vm_exec']) assert.match(config, new RegExp(`"${name}"`));
|
|
272
|
+
for (const name of ['destroy', 'secret_value', 'stack_apply']) assert.doesNotMatch(config, new RegExp(`"${name}"`));
|
|
273
|
+
assert.match(config, /--toolsets","vm-deploy"/);
|
|
274
|
+
assert.match(config, /tool_timeout_sec = 1200/);
|
|
275
|
+
assert.match(config, /env_vars = \["CLOUDRU_VM_BIN","CLOUDRU_HUB_CREDENTIALS","CLOUDRU_VM_HOME","CLOUDRU_HUB_APPROVALS"\]/);
|
|
276
|
+
assert.match(config, /default_tools_approval_mode = "auto"/);
|
|
277
|
+
|
|
278
|
+
const hermesDir = tmp();
|
|
279
|
+
assert.equal(install('hermes', hermesDir, { hermesHome: hermesDir, toolsets: 'vm-deploy', resolvedEngine: pinnedEngine }).code, 0);
|
|
280
|
+
const yaml = YAML.parse(fs.readFileSync(path.join(hermesDir, 'config.yaml'), 'utf8'));
|
|
281
|
+
assert.ok(yaml.mcp_servers.cloudru_hub.tools.include.includes('deploy_apply'));
|
|
282
|
+
assert.ok(!yaml.mcp_servers.cloudru_hub.tools.include.includes('destroy'));
|
|
283
|
+
assert.equal(yaml.mcp_servers.cloudru_hub.timeout, 1200);
|
|
284
|
+
|
|
285
|
+
const opencodeDir = tmp();
|
|
286
|
+
assert.equal(install('opencode', opencodeDir, { toolsets: 'vm-deploy' }).code, 0);
|
|
287
|
+
const opencode = JSON.parse(fs.readFileSync(path.join(opencodeDir, 'opencode.json'), 'utf8'));
|
|
288
|
+
assert.equal(opencode.mcp.cloudru_hub.timeout, 1_200_000, 'OpenCode uses milliseconds');
|
|
289
|
+
assert.equal(install('opencode', opencodeDir, { toolsets: 'vm-deploy' }).code, 0);
|
|
290
|
+
|
|
291
|
+
const piDir = tmp();
|
|
292
|
+
assert.equal(install('pi', piDir, { toolsets: 'vm-deploy' }).code, 0);
|
|
293
|
+
const pi = JSON.parse(fs.readFileSync(path.join(piDir, '.pi/mcp.json'), 'utf8'));
|
|
294
|
+
assert.equal(pi.mcpServers.cloudru_hub.timeout, 1200, 'Pi uses seconds');
|
|
295
|
+
assert.equal(install('pi', piDir, { toolsets: 'vm-deploy' }).code, 0);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
test('k8s-deploy installs the bounded profile across five hosts and refuses a raw Claude server', () => {
|
|
299
|
+
const codexDir = tmp();
|
|
300
|
+
assert.equal(install('codex', codexDir, { toolsets: 'k8s-deploy' }).code, 0);
|
|
301
|
+
const codex = fs.readFileSync(path.join(codexDir, '.codex/config.toml'), 'utf8');
|
|
302
|
+
assert.match(codex, /k8s-deploy/);
|
|
303
|
+
assert.match(codex, /tool_timeout_sec = 1200/);
|
|
304
|
+
assert.doesNotMatch(codex, /k8s_create/);
|
|
305
|
+
|
|
306
|
+
const hermesDir = tmp();
|
|
307
|
+
assert.equal(install('hermes', hermesDir, { toolsets: 'k8s-deploy', hermesHome: hermesDir, resolvedEngine: pinnedEngine }).code, 0);
|
|
308
|
+
const hermes = YAML.parse(fs.readFileSync(path.join(hermesDir, 'config.yaml'), 'utf8'));
|
|
309
|
+
assert.deepEqual(hermes.mcp_servers.cloudru_hub.tools.include, ['k8s_apply', 'k8s_get', 'k8s_kubectl', 'k8s_list', 'k8s_nodepools', 'k8s_options']);
|
|
310
|
+
assert.equal(hermes.mcp_servers.cloudru_hub.timeout, 1200);
|
|
311
|
+
|
|
312
|
+
const opencodeDir = tmp();
|
|
313
|
+
assert.equal(install('opencode', opencodeDir, { toolsets: 'k8s-deploy' }).code, 0);
|
|
314
|
+
assert.equal(JSON.parse(fs.readFileSync(path.join(opencodeDir, 'opencode.json'), 'utf8')).mcp.cloudru_hub.timeout, 1_200_000);
|
|
315
|
+
|
|
316
|
+
const piDir = tmp();
|
|
317
|
+
assert.equal(install('pi', piDir, { toolsets: 'k8s-deploy' }).code, 0);
|
|
318
|
+
assert.equal(JSON.parse(fs.readFileSync(path.join(piDir, '.pi/mcp.json'), 'utf8')).mcpServers.cloudru_hub.timeout, 1200);
|
|
319
|
+
|
|
320
|
+
const claudeDir = tmp();
|
|
321
|
+
assert.equal(install('claude-code', claudeDir, { toolsets: 'k8s-deploy' }).code, 0);
|
|
322
|
+
const mcp = JSON.parse(fs.readFileSync(path.join(claudeDir, '.mcp.json'), 'utf8'));
|
|
323
|
+
assert.ok(mcp.mcpServers.cloudru_hub.args.includes('k8s-deploy'));
|
|
324
|
+
assert.equal(mcp.mcpServers['cloudru-vm'], undefined);
|
|
325
|
+
assert.deepEqual(JSON.parse(fs.readFileSync(path.join(claudeDir, '.claude/settings.json'), 'utf8')).enabledMcpjsonServers, ['cloudru_hub']);
|
|
326
|
+
assert.equal(install('claude-code', claudeDir, { toolsets: 'k8s-deploy' }).code, 0);
|
|
327
|
+
assert.equal(install('claude-code', claudeDir, { toolsets: 'k8s-deploy', npx: true }).code, 2);
|
|
328
|
+
const rawDir = tmp();
|
|
329
|
+
fs.writeFileSync(path.join(rawDir, '.mcp.json'), JSON.stringify({ mcpServers: { 'cloudru-vm': { command: 'unsafe' } } }));
|
|
330
|
+
assert.equal(install('claude-code', rawDir, { toolsets: 'k8s-deploy' }).code, 2);
|
|
331
|
+
const invalidMcp = tmp();
|
|
332
|
+
fs.writeFileSync(path.join(invalidMcp, '.mcp.json'), '[]\n');
|
|
333
|
+
assert.equal(install('claude-code', invalidMcp, { toolsets: 'k8s-deploy' }).code, 2);
|
|
334
|
+
assert.equal(fs.readFileSync(path.join(invalidMcp, '.mcp.json'), 'utf8'), '[]\n');
|
|
335
|
+
const invalidSettings = tmp();
|
|
336
|
+
fs.mkdirSync(path.join(invalidSettings, '.claude'));
|
|
337
|
+
fs.writeFileSync(path.join(invalidSettings, '.claude/settings.json'), '[]\n');
|
|
338
|
+
assert.equal(install('claude-code', invalidSettings, { toolsets: 'k8s-deploy' }).code, 2);
|
|
339
|
+
assert.equal(fs.readFileSync(path.join(invalidSettings, '.claude/settings.json'), 'utf8'), '[]\n');
|
|
116
340
|
});
|
|
117
341
|
|
|
118
342
|
test('unknown target is an error listing the known ones', () => {
|
|
119
343
|
const res = install('emacs', tmp());
|
|
120
344
|
assert.equal(res.code, 1);
|
|
121
345
|
assert.ok(res.lines[0].includes('unknown target'));
|
|
122
|
-
assert.equal(Object.keys(TIERS).length,
|
|
346
|
+
assert.equal(Object.keys(TIERS).length, 11);
|
|
123
347
|
});
|
|
124
348
|
|
|
125
349
|
test('probeBrake discriminates: a hook that stops vetoing FAILS the probe (broken-brake control)', () => {
|
|
@@ -0,0 +1,125 @@
|
|
|
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 YAML = require('yaml');
|
|
11
|
+
const cls = require('../data/tools-classification.json');
|
|
12
|
+
const { selectedToolNames } = require('../src/toolsets.js');
|
|
13
|
+
const { PILOT_IMAGE, validateManifest, issueK8sApproval, consumeK8sApproval, k8sSession } = require('../src/k8s-approval.js');
|
|
14
|
+
const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'cloudru-k8s-approval-'));
|
|
15
|
+
after(() => fs.rmSync(scratch, { recursive: true, force: true }));
|
|
16
|
+
const clusterId = '11111111-2222-4333-8444-555555555555';
|
|
17
|
+
const projectId = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee';
|
|
18
|
+
const credentials = path.join(scratch, 'credentials.json');
|
|
19
|
+
fs.writeFileSync(credentials, JSON.stringify({ key_id: 'test-only', secret: 'test-only', project_id: projectId }), { mode: 0o600 });
|
|
20
|
+
const env = { ...process.env, CLOUDRU_HUB_CREDENTIALS: credentials, CLOUDRU_HUB_APPROVALS: path.join(scratch, 'approvals') };
|
|
21
|
+
|
|
22
|
+
function docs() {
|
|
23
|
+
const metadata = (name) => ({ name, namespace: 'snake-pilot' });
|
|
24
|
+
const app = 'snake-game';
|
|
25
|
+
return [
|
|
26
|
+
{ apiVersion: 'v1', kind: 'ConfigMap', metadata: metadata('snake-game-html'), data: { 'index.html': '<!doctype html><title>Snake</title>' } },
|
|
27
|
+
{ apiVersion: 'apps/v1', kind: 'Deployment', metadata: metadata(app), spec: {
|
|
28
|
+
replicas: 1, selector: { matchLabels: { app } }, template: { metadata: { labels: { app } }, spec: {
|
|
29
|
+
automountServiceAccountToken: false, securityContext: { runAsNonRoot: true },
|
|
30
|
+
containers: [{ name: app, image: PILOT_IMAGE, command: ['httpd'], args: ['-f', '-p', '8080', '-h', '/www'],
|
|
31
|
+
ports: [{ containerPort: 8080 }], readinessProbe: { httpGet: { path: '/', port: 8080 } },
|
|
32
|
+
resources: { requests: { cpu: '50m', memory: '32Mi' }, limits: { cpu: '100m', memory: '64Mi' } },
|
|
33
|
+
securityContext: { allowPrivilegeEscalation: false, readOnlyRootFilesystem: true, runAsUser: 10001, runAsGroup: 10001, capabilities: { drop: ['ALL'] } },
|
|
34
|
+
volumeMounts: [{ name: 'html', mountPath: '/www', readOnly: true }] }],
|
|
35
|
+
volumes: [{ name: 'html', configMap: { name: 'snake-game-html' } }],
|
|
36
|
+
} } } },
|
|
37
|
+
{ apiVersion: 'v1', kind: 'Service', metadata: metadata(app), spec: { type: 'ClusterIP', selector: { app }, ports: [{ port: 80, targetPort: 8080 }] } },
|
|
38
|
+
];
|
|
39
|
+
}
|
|
40
|
+
const serialize = (resources) => resources.map((r) => YAML.stringify(r)).join('---\n');
|
|
41
|
+
const manifest = serialize(docs());
|
|
42
|
+
const digest = require('crypto').createHash('sha256').update(manifest).digest('hex');
|
|
43
|
+
const manifestFile = path.join(scratch, 'snake.yaml');
|
|
44
|
+
fs.writeFileSync(manifestFile, manifest);
|
|
45
|
+
|
|
46
|
+
test('bounded Kubernetes manifest accepts exactly the reviewed pod-native resources', () => {
|
|
47
|
+
assert.deepEqual(validateManifest(manifest, 'snake-pilot').resources.map((r) => r.kind), ['ConfigMap', 'Deployment', 'Service']);
|
|
48
|
+
assert.deepEqual(selectedToolNames('k8s-deploy'), ['k8s_apply', 'k8s_get', 'k8s_kubectl', 'k8s_list', 'k8s_nodepools', 'k8s_options']);
|
|
49
|
+
for (const name of selectedToolNames('k8s-deploy')) {
|
|
50
|
+
assert.equal(cls.tools[name].permission, ['k8s_apply', 'k8s_kubectl'].includes(name) ? 'ask' : 'allow');
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('unexpected mutators, exposure, privilege, image and namespace are rejected', () => {
|
|
55
|
+
const mutations = [
|
|
56
|
+
(r) => { r[1].spec.template.spec.volumes.push({ name: 'host', hostPath: { path: '/etc' } }); },
|
|
57
|
+
(r) => { r[1].spec.template.spec.containers[0].securityContext.privileged = true; },
|
|
58
|
+
(r) => { r[1].spec.template.spec.containers[0].command = ['sh']; },
|
|
59
|
+
(r) => { r[1].spec.template.spec.containers[0].image = 'docker.io/library/busybox@sha256:' + 'f'.repeat(64); },
|
|
60
|
+
(r) => { r[2].spec.type = 'LoadBalancer'; },
|
|
61
|
+
(r) => { r[0].metadata.namespace = 'default'; },
|
|
62
|
+
(r) => { r.push({ apiVersion: 'v1', kind: 'Secret', metadata: { name: 's', namespace: 'snake-pilot' } }); },
|
|
63
|
+
];
|
|
64
|
+
for (const mutate of mutations) {
|
|
65
|
+
const r = docs(); mutate(r);
|
|
66
|
+
assert.throws(() => validateManifest(serialize(r), 'snake-pilot'));
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('digest, project, expiry and single-use approval bind the exact manifest', () => {
|
|
71
|
+
assert.throws(() => issueK8sApproval(clusterId, 'snake-pilot', manifestFile, '0'.repeat(64), env), /digest differs/);
|
|
72
|
+
const a = issueK8sApproval(clusterId, 'snake-pilot', manifestFile, digest, env, 1000);
|
|
73
|
+
assert.equal(a.manifestSha256, digest);
|
|
74
|
+
const used = consumeK8sApproval(a.approvalId, env, 1100);
|
|
75
|
+
assert.equal(used.manifest, manifest);
|
|
76
|
+
assert.equal(k8sSession(a.approvalId, env, 1200).namespace, 'snake-pilot');
|
|
77
|
+
assert.throws(() => consumeK8sApproval(a.approvalId, env, 1200));
|
|
78
|
+
const b = issueK8sApproval(clusterId, 'snake-pilot', manifestFile, digest, env, 2000);
|
|
79
|
+
const otherCredentials = path.join(scratch, 'other.json');
|
|
80
|
+
fs.writeFileSync(otherCredentials, JSON.stringify({ key_id: 'test-other', secret: 'test-other', project_id: 'ffffffff-eeee-4ddd-8ccc-bbbbbbbbbbbb' }), { mode: 0o600 });
|
|
81
|
+
assert.throws(() => consumeK8sApproval(b.approvalId, { ...env, CLOUDRU_HUB_CREDENTIALS: otherCredentials }, 2100), /does not match/);
|
|
82
|
+
assert.throws(() => consumeK8sApproval(b.approvalId, env, 2000 + 10 * 60_000));
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('MCP proxy forwards only locally approved apply and four fixed verification queries', () => {
|
|
86
|
+
const a = issueK8sApproval(clusterId, 'snake-pilot', manifestFile, digest, env);
|
|
87
|
+
const engine = path.join(scratch, 'fake-engine');
|
|
88
|
+
const log = path.join(scratch, 'forwarded.jsonl');
|
|
89
|
+
fs.writeFileSync(engine, `#!/usr/bin/env node
|
|
90
|
+
const fs=require('fs');let b='';process.stdin.setEncoding('utf8');process.stdin.on('data',x=>{b+=x;let i;while((i=b.indexOf('\\n'))>=0){const line=b.slice(0,i);b=b.slice(i+1);if(!line)continue;const m=JSON.parse(line);fs.appendFileSync(process.env.FAKE_LOG,JSON.stringify(m)+'\\n');if(!Object.hasOwn(m,'id'))continue;let result={};if(m.method==='tools/list')result={tools:[{name:'k8s_apply'},{name:'k8s_kubectl'},{name:'k8s_create'}]};else result={content:[{type:'text',text:'{"status":"applied"}'}]};process.stdout.write(JSON.stringify({jsonrpc:'2.0',id:m.id,result})+'\\n');}});
|
|
91
|
+
`);
|
|
92
|
+
fs.chmodSync(engine, 0o755);
|
|
93
|
+
const script = `const {runSafeMcp}=require(${JSON.stringify(path.resolve(import.meta.dirname, '../src/safe-mcp.js'))});runSafeMcp({toolsets:'k8s-deploy',resolved:{path:${JSON.stringify(engine)},verified:true,sha256:${JSON.stringify(cls.engineSha256)}}});`;
|
|
94
|
+
const msg = (id, name, args) => JSON.stringify({ jsonrpc: '2.0', id, method: 'tools/call', params: { name, arguments: args } });
|
|
95
|
+
const lines = [
|
|
96
|
+
JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list' }),
|
|
97
|
+
msg(2, 'k8s_apply', { approval_id: a.approvalId, cluster_id: 'wrong', confirmed: true }),
|
|
98
|
+
msg(3, 'k8s_apply', { approval_id: a.approvalId }),
|
|
99
|
+
msg(4, 'k8s_apply', { approval_id: a.approvalId }),
|
|
100
|
+
msg(5, 'k8s_kubectl', { approval_id: a.approvalId, query: 'delete' }),
|
|
101
|
+
msg(6, 'k8s_kubectl', { approval_id: a.approvalId, query: 'pods', args: 'delete pods -A' }),
|
|
102
|
+
msg(7, 'k8s_kubectl', { approval_id: a.approvalId, query: 'pods' }),
|
|
103
|
+
msg(8, 'k8s_kubectl', { approval_id: a.approvalId, query: 'deployment' }),
|
|
104
|
+
msg(9, 'k8s_kubectl', { approval_id: a.approvalId, query: 'service' }),
|
|
105
|
+
msg(10, 'k8s_kubectl', { approval_id: a.approvalId, query: 'rollout' }),
|
|
106
|
+
msg(11, 'k8s_create', { confirmed: true }),
|
|
107
|
+
msg(12, 'k8s_apply', { approval_id: a.approvalId, create_only: false }),
|
|
108
|
+
];
|
|
109
|
+
fs.writeFileSync(log, '');
|
|
110
|
+
const r = spawnSync(process.execPath, ['-e', script], { input: lines.join('\n') + '\n', encoding: 'utf8', timeout: 5000,
|
|
111
|
+
env: { ...env, FAKE_LOG: log } });
|
|
112
|
+
assert.equal(r.status, 0, r.stderr);
|
|
113
|
+
const responses = r.stdout.trim().split('\n').map(JSON.parse);
|
|
114
|
+
const byId = Object.fromEntries(responses.map((x) => [x.id, x]));
|
|
115
|
+
assert.deepEqual(byId[1].result.tools.map((x) => x.name), ['k8s_apply', 'k8s_kubectl']);
|
|
116
|
+
assert.deepEqual(Object.keys(byId[1].result.tools[0].inputSchema.properties), ['approval_id']);
|
|
117
|
+
for (const id of [2, 4, 5, 6, 11, 12]) assert.ok(byId[id].error, `request ${id} rejected`);
|
|
118
|
+
const forwarded = fs.readFileSync(log, 'utf8').trim().split('\n').map(JSON.parse).filter((x) => x.method === 'tools/call');
|
|
119
|
+
assert.deepEqual(forwarded.map((x) => x.params.name), ['k8s_apply', 'k8s_kubectl', 'k8s_kubectl', 'k8s_kubectl', 'k8s_kubectl']);
|
|
120
|
+
assert.deepEqual(forwarded[0].params.arguments, { cluster_id: clusterId, namespace: 'snake-pilot', manifest, create_only: true, confirmed: true });
|
|
121
|
+
assert.deepEqual(forwarded[1].params.arguments, { cluster_id: clusterId, args: '-n snake-pilot get pods -l app=snake-game -o json' });
|
|
122
|
+
assert.deepEqual(forwarded[2].params.arguments, { cluster_id: clusterId, args: '-n snake-pilot get deployment snake-game -o json' });
|
|
123
|
+
assert.deepEqual(forwarded[3].params.arguments, { cluster_id: clusterId, args: '-n snake-pilot get service snake-game -o json' });
|
|
124
|
+
assert.deepEqual(forwarded[4].params.arguments, { cluster_id: clusterId, args: '-n snake-pilot rollout status deployment/snake-game --timeout=90s' });
|
|
125
|
+
});
|
|
@@ -74,18 +74,18 @@
|
|
|
74
74
|
"minFailing": 1
|
|
75
75
|
},
|
|
76
76
|
{
|
|
77
|
-
"id": "
|
|
78
|
-
"property": "
|
|
77
|
+
"id": "stack-status-remains-observational",
|
|
78
|
+
"property": "After the engine fix, stack_status reads persisted job state without advancing steps; stack_apply remains the explicitly gated mutation. Reclassifying status as ask loses the read-only contract and must fail the golden test.",
|
|
79
79
|
"file": "data/tools-classification.json",
|
|
80
80
|
"mutation": {
|
|
81
|
-
"find": "\"stack_status\": {\n \"effect\": \"
|
|
82
|
-
"replace": "\"stack_status\": {\n \"effect\": \"
|
|
81
|
+
"find": "\"stack_status\": {\n \"effect\": \"read-only\",\n \"permission\": \"allow\",",
|
|
82
|
+
"replace": "\"stack_status\": {\n \"effect\": \"read-only\",\n \"permission\": \"ask\","
|
|
83
83
|
},
|
|
84
|
-
"minFailing":
|
|
84
|
+
"minFailing": 1
|
|
85
85
|
},
|
|
86
86
|
{
|
|
87
87
|
"id": "brake-logs-stays-ask",
|
|
88
|
-
"property": "
|
|
88
|
+
"property": "The pinned engine quotes logs.service before remote execution, but logs still reaches SSH/compose and can disclose sensitive output. Flipping its classification from ask to allow violates the reviewed shell-reach boundary.",
|
|
89
89
|
"file": "data/tools-classification.json",
|
|
90
90
|
"mutation": {
|
|
91
91
|
"find": "\"logs\": {\n \"effect\": \"shell-reach\",\n \"permission\": \"ask\",",
|