@dzhechkov/cloudru-hub 0.1.4
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 +105 -0
- package/CHANGELOG.md +130 -0
- package/LICENSE +67 -0
- package/README.md +131 -0
- package/THIRD_PARTY_NOTICES.md +56 -0
- package/bin/cloudru-hub.js +8 -0
- package/data/dialects.json +73 -0
- package/data/pointer-section.md +22 -0
- package/data/tools-classification.json +732 -0
- package/data/tools-list-snapshot.json +585 -0
- package/docs/LOCAL-TESTING.md +77 -0
- package/package.json +67 -0
- package/sbom.json +253 -0
- package/src/classification.js +66 -0
- package/src/cli.js +174 -0
- package/src/dialects.js +172 -0
- package/src/install.js +252 -0
- package/src/resolve.js +101 -0
- package/templates/.claude/hooks/cloudru-ssh-guard.cjs +80 -0
- package/test/classification.test.mjs +151 -0
- package/test/dialects.test.mjs +314 -0
- package/test/hook.test.mjs +67 -0
- package/test/install.test.mjs +145 -0
- package/test/mutation-registry.json +135 -0
- package/test/pack-holds.test.mjs +96 -0
- package/test/resolve.test.mjs +81 -0
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
// Skill dialect compiler tests — ADR-006 Confirmation, all layer 1: size, links, dialect
|
|
2
|
+
// grep, determinism. Uses a SYNTHETIC fixture (our own text carrying the Hermes-dialect
|
|
3
|
+
// tokens) — the real canonical corpus is upstream content and never ships in this package.
|
|
4
|
+
import { test } from 'node:test';
|
|
5
|
+
import assert from 'node:assert/strict';
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import os from 'node:os';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { createRequire } from 'node:module';
|
|
10
|
+
|
|
11
|
+
const require = createRequire(import.meta.url);
|
|
12
|
+
const { compileSkill, loadDialects, filterFrontmatter } = require('../src/dialects.js');
|
|
13
|
+
|
|
14
|
+
function fixture() {
|
|
15
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cloudru-dialects-'));
|
|
16
|
+
fs.writeFileSync(path.join(dir, 'SKILL.md'), `---
|
|
17
|
+
name: cloudru-hub
|
|
18
|
+
description: Управление Cloud.ru Evolution.
|
|
19
|
+
metadata:
|
|
20
|
+
hermes:
|
|
21
|
+
trust_tier: high
|
|
22
|
+
---
|
|
23
|
+
# Skill
|
|
24
|
+
|
|
25
|
+
Сначала прогони tool_search по каталогу, затем tool_describe нужного тула.
|
|
26
|
+
Вызови mcp__cloudru_vm__deploy_plan и покажи план капитану.
|
|
27
|
+
Для поллинга: hermes cron create '6m' status --deliver telegram.
|
|
28
|
+
Логи пиши в /opt/data/cloudru/logs, SOUL-контекст не трогай.
|
|
29
|
+
Подробнее: [деплой](modules/deploy.md) и [несуществующее](modules/models.md).
|
|
30
|
+
`);
|
|
31
|
+
fs.mkdirSync(path.join(dir, 'modules'));
|
|
32
|
+
fs.writeFileSync(path.join(dir, 'modules', 'deploy.md'), 'Деплой: спроси капитана, затем mcp__cloudru_vm__deploy_apply c confirmed=true.\n');
|
|
33
|
+
return dir;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
test('claude-code dialect: forbidden Hermes tokens are gone, tool prefix normalized', () => {
|
|
37
|
+
const result = compileSkill(fixture(), 'claude-code');
|
|
38
|
+
assert.equal(result.ok, true, result.report.errors.join('; '));
|
|
39
|
+
const skill = result.files['SKILL.md'];
|
|
40
|
+
const cfg = loadDialects();
|
|
41
|
+
for (const token of cfg.forbiddenHermesTokens) {
|
|
42
|
+
for (const [rel, text] of Object.entries(result.files)) {
|
|
43
|
+
if (!rel.endsWith('.md')) continue;
|
|
44
|
+
assert.ok(!text.includes(token), `${rel} still contains "${token}"`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
assert.ok(skill.includes('mcp__cloudru-vm__deploy_plan'), 'underscore server name normalized to hyphen');
|
|
48
|
+
assert.ok(result.files['modules/deploy.md'].includes('пользователя'), 'капитан → пользователь');
|
|
49
|
+
assert.ok(skill.includes('ToolSearch'), 'tool_search section replaced with the native deferred-tools contract');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('codex dialect compiles clean too, with its own tool-search text', () => {
|
|
53
|
+
const result = compileSkill(fixture(), 'codex');
|
|
54
|
+
assert.equal(result.ok, true, result.report.errors.join('; '));
|
|
55
|
+
assert.ok(result.files['SKILL.md'].includes('tool-search рантайма'));
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('dangling links are UNLINKED and reported; resolvable links survive', () => {
|
|
59
|
+
const result = compileSkill(fixture(), 'claude-code');
|
|
60
|
+
assert.equal(result.report.dangling.length, 1);
|
|
61
|
+
assert.match(result.report.dangling[0], /models\.md/);
|
|
62
|
+
const skill = result.files['SKILL.md'];
|
|
63
|
+
assert.ok(skill.includes('[деплой](modules/deploy.md)'), 'live link kept');
|
|
64
|
+
assert.ok(!skill.includes('(modules/models.md)'), 'dead link target removed');
|
|
65
|
+
assert.ok(skill.includes('несуществующее'), 'link text kept');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('frontmatter keeps only name + description — metadata.hermes does not travel', () => {
|
|
69
|
+
const result = compileSkill(fixture(), 'claude-code');
|
|
70
|
+
const skill = result.files['SKILL.md'];
|
|
71
|
+
assert.match(skill, /name: cloudru-hub/);
|
|
72
|
+
assert.match(skill, /description: /);
|
|
73
|
+
assert.ok(!skill.includes('trust_tier'));
|
|
74
|
+
assert.ok(!skill.includes('metadata:'));
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('determinism: compiling twice is byte-identical (the property that forbids hand forks)', () => {
|
|
78
|
+
const dir = fixture();
|
|
79
|
+
const a = compileSkill(dir, 'claude-code');
|
|
80
|
+
const b = compileSkill(dir, 'claude-code');
|
|
81
|
+
assert.deepEqual(a.files, b.files);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('lossy layout enforces the 12000-char router cap as a hard error', () => {
|
|
85
|
+
const dir = fixture();
|
|
86
|
+
fs.writeFileSync(path.join(dir, 'SKILL.md'), '# big\n' + 'x'.repeat(13000));
|
|
87
|
+
const cfg = loadDialects();
|
|
88
|
+
const lossyCfg = { ...cfg, targets: { ...cfg.targets, lossy: { layout: 'lossy', replacements: cfg.targets['claude-code'].replacements } } };
|
|
89
|
+
const result = compileSkill(dir, 'lossy', lossyCfg);
|
|
90
|
+
assert.equal(result.ok, false);
|
|
91
|
+
assert.match(result.report.errors.join(' '), /12000/);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('a surviving forbidden token is a HARD compile failure, never a shipped lie', () => {
|
|
95
|
+
const dir = fixture();
|
|
96
|
+
const cfg = loadDialects();
|
|
97
|
+
const crippled = {
|
|
98
|
+
...cfg,
|
|
99
|
+
targets: { ...cfg.targets, 'claude-code': { layout: 'lossless', replacements: [] } },
|
|
100
|
+
};
|
|
101
|
+
const result = compileSkill(dir, 'claude-code', crippled);
|
|
102
|
+
assert.equal(result.ok, false);
|
|
103
|
+
assert.match(result.report.errors.join(' '), /forbidden Hermes-dialect tokens survive/);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// --- codename gate (2026-08-10, cross-model QE MEDIUM): the 8-token denylist let the
|
|
107
|
+
// bare word "Hermes" and the ~/.hermes/ path through (real-corpus compile returned
|
|
108
|
+
// ok=true with 5 case-insensitive survivors — MEASURED). The gate is now a POSITIVE
|
|
109
|
+
// invariant: no occurrence of the codename, in any case, in any emitted file. ---
|
|
110
|
+
|
|
111
|
+
test('codename gate: an unscrubbed "Hermes" in ANY casing is a HARD compile failure', () => {
|
|
112
|
+
for (const planted of ['Движок Hermes сказал так.', 'РЕЖИМ HERMES АКТИВЕН.', 'малый hermesовский хвост']) {
|
|
113
|
+
const dir = fixture();
|
|
114
|
+
fs.appendFileSync(path.join(dir, 'modules', 'deploy.md'), '\n' + planted + '\n');
|
|
115
|
+
const result = compileSkill(dir, 'claude-code');
|
|
116
|
+
assert.equal(result.ok, false, `planted "${planted}" must fail the compile`);
|
|
117
|
+
assert.match(result.report.errors.join(' '), /codename "hermes"/, planted);
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('codename gate covers non-.md files too (emitted verbatim, so a leak there is caught, not shipped)', () => {
|
|
122
|
+
const dir = fixture();
|
|
123
|
+
fs.writeFileSync(path.join(dir, 'helper.sh'), '#!/bin/sh\necho hermes home is ~/.hermes\n');
|
|
124
|
+
const result = compileSkill(dir, 'claude-code');
|
|
125
|
+
assert.equal(result.ok, false);
|
|
126
|
+
assert.match(result.report.errors.join(' '), /helper\.sh: codename "hermes"/);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('the real-corpus leak shapes are SCRUBBED: ~/.hermes/ path, hermes-agent name, prose mentions', () => {
|
|
130
|
+
const dir = fixture();
|
|
131
|
+
fs.appendFileSync(path.join(dir, 'SKILL.md'),
|
|
132
|
+
'\nПути — от workspace root (с хоста ВМ это `~/.hermes/workspace/`, кэш в `.hermes/memory`).\n' +
|
|
133
|
+
'### Стоп-правила (уроки Hermes 30.06 + 15.07)\n' +
|
|
134
|
+
'Дубль создания = осиротевшие VM (так при Hermes возникли 2 лишние VM).\n');
|
|
135
|
+
fs.appendFileSync(path.join(dir, 'modules', 'deploy.md'), '\n "name": "hermes-agent",\n');
|
|
136
|
+
const result = compileSkill(dir, 'claude-code');
|
|
137
|
+
assert.equal(result.ok, true, result.report.errors.join('; '));
|
|
138
|
+
for (const [rel, text] of Object.entries(result.files)) {
|
|
139
|
+
assert.ok(!/hermes/i.test(text), `${rel} still mentions the codename`);
|
|
140
|
+
}
|
|
141
|
+
assert.ok(result.files['SKILL.md'].includes('~/.cloudru-hub/workspace/'), 'path remapped, not deleted');
|
|
142
|
+
assert.ok(result.files['modules/deploy.md'].includes('"agent-vm"'), 'example VM name neutralized');
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// --- codename gate: full engine-identity set (AM-2, 2026-08-10). The allowlist covered only
|
|
146
|
+
// "hermes"; the rest of the engine identity — `dzhechko` (go-module owner), `cloudru-vm-cli`
|
|
147
|
+
// (CLI name), `captainkeys` (captainkeys.go) — compiled CLEAN (MEASURED). Now each hard-fails. ---
|
|
148
|
+
|
|
149
|
+
test('codename gate: each engine-identity token (dzhechko, cloudru-vm-cli, captainkeys) hard-fails when it SURVIVES scrubbing', () => {
|
|
150
|
+
// The gate is the net UNDER the scrub — it fires on any token that reaches an emitted
|
|
151
|
+
// file. To exercise the gate itself (not the scrub replacements that would neutralize
|
|
152
|
+
// these), compile with replacements disabled (as test 22 does) and plant each token.
|
|
153
|
+
const cfg = loadDialects();
|
|
154
|
+
for (const [planted, needle] of [
|
|
155
|
+
['Автор движка — dzhechko лично.', 'dzhechko'],
|
|
156
|
+
['Собрано из cloudru-vm-cli как есть.', 'cloudru-vm-cli'],
|
|
157
|
+
['Ключи инжектит captainkeys напрямую.', 'captainkeys'],
|
|
158
|
+
]) {
|
|
159
|
+
const dir = fixture();
|
|
160
|
+
fs.appendFileSync(path.join(dir, 'modules', 'deploy.md'), '\n' + planted + '\n');
|
|
161
|
+
const crippled = { ...cfg, targets: { ...cfg.targets, 'claude-code': { layout: 'lossless', replacements: [] } } };
|
|
162
|
+
const result = compileSkill(dir, 'claude-code', crippled);
|
|
163
|
+
assert.equal(result.ok, false, `planted "${planted}" must fail the compile`);
|
|
164
|
+
assert.ok(result.report.forbidden.some((f) => f.includes(`codename "${needle}"`)),
|
|
165
|
+
`${planted}: expected a codename "${needle}" finding, got ${JSON.stringify(result.report.forbidden)}`);
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test('RED-before / GREEN-after: dzhechko/cloudru-vm-cli/captainkeys compiled CLEAN under the old ["hermes"]-only list, now hard-fail', () => {
|
|
170
|
+
const cfg = loadDialects();
|
|
171
|
+
const dir = fixture();
|
|
172
|
+
fs.appendFileSync(path.join(dir, 'modules', 'deploy.md'), '\ndzhechko / cloudru-vm-cli / captainkeys\n');
|
|
173
|
+
const noScrub = { layout: 'lossless', replacements: [] };
|
|
174
|
+
// RED-before: the previous single-token list let all three through.
|
|
175
|
+
const before = compileSkill(dir, 'claude-code', { ...cfg, forbiddenCodenames: ['hermes'], targets: { ...cfg.targets, 'claude-code': noScrub } });
|
|
176
|
+
for (const tok of ['dzhechko', 'cloudru-vm-cli', 'captainkeys']) {
|
|
177
|
+
assert.ok(!before.report.forbidden.some((f) => f.includes(`codename "${tok}"`)), `old list must NOT flag ${tok}`);
|
|
178
|
+
}
|
|
179
|
+
// GREEN-after: the shipped expanded list flags each one.
|
|
180
|
+
const after = compileSkill(dir, 'claude-code', { ...cfg, targets: { ...cfg.targets, 'claude-code': noScrub } });
|
|
181
|
+
for (const tok of ['dzhechko', 'cloudru-vm-cli', 'captainkeys']) {
|
|
182
|
+
assert.ok(after.report.forbidden.some((f) => f.includes(`codename "${tok}"`)), `expanded list must flag ${tok}`);
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test('word-boundary: the published scope @dzhechkov is NOT over-blocked (dzhechko is only a substring of it)', () => {
|
|
187
|
+
const dir = fixture();
|
|
188
|
+
// @dzhechkov/cloudru-hub is the legitimate published identity — must survive the dzhechko net.
|
|
189
|
+
fs.appendFileSync(path.join(dir, 'SKILL.md'), '\nУстановка: `npx @dzhechkov/cloudru-hub install`.\n');
|
|
190
|
+
const result = compileSkill(dir, 'claude-code');
|
|
191
|
+
assert.equal(result.ok, true, result.report.errors.join('; '));
|
|
192
|
+
assert.ok(result.files['SKILL.md'].includes('@dzhechkov/cloudru-hub'), 'published scope kept verbatim');
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test('word-boundary: a Latin word merely CONTAINING a codename (thermes) is not a false positive', () => {
|
|
196
|
+
const dir = fixture();
|
|
197
|
+
fs.appendFileSync(path.join(dir, 'SKILL.md'), '\nСлово thermes и arithmetic не про движок.\n');
|
|
198
|
+
const result = compileSkill(dir, 'claude-code');
|
|
199
|
+
assert.equal(result.ok, true, result.report.errors.join('; '));
|
|
200
|
+
assert.ok(result.files['SKILL.md'].includes('thermes'), 'thermes survives — not the codename');
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test('the engine-identity leak shapes are SCRUBBED: go-module path, cloudru-vm-cli, captainkeys', () => {
|
|
204
|
+
const dir = fixture();
|
|
205
|
+
fs.appendFileSync(path.join(dir, 'modules', 'deploy.md'),
|
|
206
|
+
'\nДвижок: `github.com/dzhechko/cloudru-vm-cli`, бинарь cloudru-vm-cli; ключи из captainkeys.go.\n');
|
|
207
|
+
const result = compileSkill(dir, 'claude-code');
|
|
208
|
+
assert.equal(result.ok, true, result.report.errors.join('; '));
|
|
209
|
+
const text = result.files['modules/deploy.md'];
|
|
210
|
+
assert.ok(text.includes('@dzhechkov/cloudru-hub'), 'go-module path remapped to the published package');
|
|
211
|
+
assert.ok(!/\bcloudru-vm-cli\b/i.test(text), 'CLI name scrubbed');
|
|
212
|
+
assert.ok(!/\bdzhechko\b/i.test(text), 'engine handle scrubbed');
|
|
213
|
+
assert.ok(!/\bcaptainkeys\b/i.test(text), 'captainkeys scrubbed');
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
// --- engine binary-name leak forms (0.1.3, Variant A): the bare token `cloudru-vm` is BOTH
|
|
217
|
+
// the engine binary AND the legitimate MCP server name that MUST appear in output. PROVEN:
|
|
218
|
+
// \bcloudru-vm\b matches the compiler's OWN injected tool_search prose "сервера cloudru-vm
|
|
219
|
+
// видны" (over-block), and mcp__cloudru-vm__ is spared only because `_` is a word char — so
|
|
220
|
+
// the bare token must NEVER enter forbiddenCodenames. Instead the two UNAMBIGUOUS leak
|
|
221
|
+
// FORMS are scrubbed: the dotted config path `.cloudru-vm` and the CLI invocation
|
|
222
|
+
// `cloudru-vm <real-engine-verb>` (finite verb set from engine cmd/cloudru-vm/main.go). ---
|
|
223
|
+
|
|
224
|
+
const ENGINE_VERBS = ['deploy', 'status', 'logs', 'destroy', 'verify', 'init', 'version', 'list-zones', 'list-images', 'mcp', 'doctor'];
|
|
225
|
+
|
|
226
|
+
test('over-block guard: the legit MCP server name cloudru-vm (prose + mcp__ tool id) compiles CLEAN and is NOT rewritten', () => {
|
|
227
|
+
for (const target of ['claude-code', 'codex']) {
|
|
228
|
+
const dir = fixture();
|
|
229
|
+
fs.appendFileSync(path.join(dir, 'SKILL.md'),
|
|
230
|
+
'\nИнструменты сервера cloudru-vm видны в сессии.\nВызови mcp__cloudru-vm__stack_status и жди.\n');
|
|
231
|
+
const result = compileSkill(dir, target);
|
|
232
|
+
assert.equal(result.ok, true, `${target}: ${result.report.errors.join('; ')}`);
|
|
233
|
+
assert.equal(result.report.forbidden.length, 0, `${target}: ${JSON.stringify(result.report.forbidden)}`);
|
|
234
|
+
const skill = result.files['SKILL.md'];
|
|
235
|
+
assert.ok(skill.includes('сервера cloudru-vm видны'), `${target}: legit MCP-server prose kept verbatim`);
|
|
236
|
+
assert.ok(skill.includes('mcp__cloudru-vm__stack_status'), `${target}: mcp__ tool id kept verbatim`);
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test('leak scrub GREEN: ~/.cloudru-vm path and `cloudru-vm <real-verb>` are rewritten to cloudru-hub forms in EVERY target', () => {
|
|
241
|
+
for (const target of ['claude-code', 'codex']) {
|
|
242
|
+
const dir = fixture();
|
|
243
|
+
fs.appendFileSync(path.join(dir, 'modules', 'deploy.md'),
|
|
244
|
+
'\nКонфиг движка лежит в ~/.cloudru-vm/config.\nЗапусти cloudru-vm deploy, потом cloudru-vm list-zones.\n');
|
|
245
|
+
const result = compileSkill(dir, target);
|
|
246
|
+
assert.equal(result.ok, true, `${target}: ${result.report.errors.join('; ')}`);
|
|
247
|
+
assert.equal(result.report.forbidden.length, 0, `${target}: 0 codename-gate survivors`);
|
|
248
|
+
const text = result.files['modules/deploy.md'];
|
|
249
|
+
assert.ok(text.includes('~/.cloudru-hub/config'), `${target}: config-dir path remapped`);
|
|
250
|
+
assert.ok(text.includes('cloudru-hub deploy'), `${target}: CLI invocation remapped`);
|
|
251
|
+
assert.ok(text.includes('cloudru-hub list-zones'), `${target}: hyphenated verb remapped too`);
|
|
252
|
+
assert.ok(!/\.cloudru-vm(?![\w-])/.test(text), `${target}: dotted leak form gone`);
|
|
253
|
+
assert.ok(!/cloudru-vm (?:deploy|list-zones)/.test(text), `${target}: invocation leak form gone`);
|
|
254
|
+
}
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test('leak scrub RED-before: without the two scrub entries the leak forms shipped INTACT with ok=true (the 0.1.2 behavior)', () => {
|
|
258
|
+
const cfg = loadDialects();
|
|
259
|
+
const isNewScrub = (r) => r.find.includes('\\.cloudru-vm(?!') || r.find.includes('\\bcloudru-vm[ \\t]+');
|
|
260
|
+
// The shipped config MUST actually carry both new entries (else this test proves nothing).
|
|
261
|
+
for (const target of ['claude-code', 'codex']) {
|
|
262
|
+
assert.equal(cfg.targets[target].replacements.filter(isNewScrub).length, 2, `${target} carries both leak-form scrubs`);
|
|
263
|
+
}
|
|
264
|
+
const old = {
|
|
265
|
+
...cfg,
|
|
266
|
+
targets: { ...cfg.targets, 'claude-code': { ...cfg.targets['claude-code'], replacements: cfg.targets['claude-code'].replacements.filter((r) => !isNewScrub(r)) } },
|
|
267
|
+
};
|
|
268
|
+
const dir = fixture();
|
|
269
|
+
fs.appendFileSync(path.join(dir, 'modules', 'deploy.md'), '\n~/.cloudru-vm/config и cloudru-vm deploy.\n');
|
|
270
|
+
const before = compileSkill(dir, 'claude-code', old);
|
|
271
|
+
assert.equal(before.ok, true, 'the codename gate alone never caught the bare-binary leak forms');
|
|
272
|
+
assert.ok(before.files['modules/deploy.md'].includes('~/.cloudru-vm/config'), 'RED: path leak survived under 0.1.2 config');
|
|
273
|
+
assert.ok(before.files['modules/deploy.md'].includes('cloudru-vm deploy'), 'RED: invocation leak survived under 0.1.2 config');
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
test('leak scrub precision: non-verb prose, a non-whole-word verb, and .cloudru-vm-cli are all untouched by the new scrubs', () => {
|
|
277
|
+
const dir = fixture();
|
|
278
|
+
fs.appendFileSync(path.join(dir, 'SKILL.md'),
|
|
279
|
+
'\nСервер cloudru-vm запускает деплой; слово cloudru-vm deployment — не команда.\n');
|
|
280
|
+
const result = compileSkill(dir, 'claude-code');
|
|
281
|
+
assert.equal(result.ok, true, result.report.errors.join('; '));
|
|
282
|
+
const skill = result.files['SKILL.md'];
|
|
283
|
+
assert.ok(skill.includes('cloudru-vm запускает'), 'Russian non-verb prose untouched');
|
|
284
|
+
assert.ok(skill.includes('cloudru-vm deployment'), 'verb alternation is whole-word ((?:…)\\b): deployment ≠ deploy');
|
|
285
|
+
// `.cloudru-vm-cli` is handled by the EARLIER (longer, ordered-first) cloudru-vm-cli scrub,
|
|
286
|
+
// and the (?![\w-]) lookahead keeps the new path scrub from mis-splitting it into `.cloudru-hub-cli`.
|
|
287
|
+
const dir2 = fixture();
|
|
288
|
+
fs.appendFileSync(path.join(dir2, 'SKILL.md'), '\nСтарый путь был .cloudru-vm-cli/cache.\n');
|
|
289
|
+
const r2 = compileSkill(dir2, 'claude-code');
|
|
290
|
+
assert.equal(r2.ok, true, r2.report.errors.join('; '));
|
|
291
|
+
assert.ok(r2.files['SKILL.md'].includes('.cloudru-hub/cache'), 'cloudru-vm-cli scrub owned it whole');
|
|
292
|
+
assert.ok(!r2.files['SKILL.md'].includes('cloudru-hub-cli'), 'no mis-split by the path scrub');
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
test('the CLI-invocation verb set matches the engine cobra command set exactly (cmd/cloudru-vm/main.go AddCommand)', () => {
|
|
296
|
+
const cfg = loadDialects();
|
|
297
|
+
for (const target of ['claude-code', 'codex']) {
|
|
298
|
+
const r = cfg.targets[target].replacements.find((x) => x.find.includes('\\bcloudru-vm[ \\t]+'));
|
|
299
|
+
assert.ok(r, `${target} has the invocation scrub`);
|
|
300
|
+
const m = r.find.match(/\(\?:\(\?:([^)]*)\)/) || r.find.match(/\(\?=\(\?:([^)]*)\)/);
|
|
301
|
+
assert.ok(m, `${target}: lookahead alternation parseable`);
|
|
302
|
+
assert.deepEqual(m[1].split('|').sort(), [...ENGINE_VERBS].sort(), `${target}: verb set is the engine's real command set`);
|
|
303
|
+
}
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
test('unknown target is refused with the known list', () => {
|
|
307
|
+
const result = compileSkill(fixture(), 'vim');
|
|
308
|
+
assert.equal(result.ok, false);
|
|
309
|
+
assert.match(result.report.errors[0], /unknown dialect target/);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
test('filterFrontmatter is a no-op on files without frontmatter', () => {
|
|
313
|
+
assert.equal(filterFrontmatter('# plain\n'), '# plain\n');
|
|
314
|
+
});
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// ADR-004 Confirmation acid tests for the PreToolUse veto hook — the brake must be
|
|
2
|
+
// EXECUTED, not merely present. Discrimination: test/mutation-registry.json deletes the
|
|
3
|
+
// sshpass regex / the rollout guard and REQUIRES these tests to go red.
|
|
4
|
+
import { test } from 'node:test';
|
|
5
|
+
import assert from 'node:assert/strict';
|
|
6
|
+
import { execFileSync, spawnSync } from 'node:child_process';
|
|
7
|
+
import { createRequire } from 'node:module';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { fileURLToPath } from 'node:url';
|
|
10
|
+
|
|
11
|
+
const require = createRequire(import.meta.url);
|
|
12
|
+
const dir = path.dirname(fileURLToPath(import.meta.url));
|
|
13
|
+
const HOOK = path.join(dir, '..', 'templates', '.claude', 'hooks', 'cloudru-ssh-guard.cjs');
|
|
14
|
+
const { decide } = require(HOOK);
|
|
15
|
+
|
|
16
|
+
function runHook(payload) {
|
|
17
|
+
const r = spawnSync(process.execPath, [HOOK], { input: JSON.stringify(payload), encoding: 'utf8' });
|
|
18
|
+
return { status: r.status, stderr: r.stderr };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
test('sshpass to a Cloud.ru VM ip is vetoed (exit 2, reason on stderr)', () => {
|
|
22
|
+
const r = runHook({ tool_name: 'Bash', tool_input: { command: 'sshpass -p hunter2 ssh user1@1.2.3.4' } });
|
|
23
|
+
assert.equal(r.status, 2);
|
|
24
|
+
assert.match(r.stderr, /sshpass/);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('ssh without -i to user1@ip is vetoed', () => {
|
|
28
|
+
const r = runHook({ tool_name: 'Bash', tool_input: { command: 'ssh user1@1.2.3.4 docker ps' } });
|
|
29
|
+
assert.equal(r.status, 2);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('keyed ssh (-i) is allowed (exit 0)', () => {
|
|
33
|
+
const r = runHook({ tool_name: 'Bash', tool_input: { command: 'ssh -i /home/u/.ssh/id_ed25519 user1@1.2.3.4 uptime' } });
|
|
34
|
+
assert.equal(r.status, 0);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('non-ssh Bash commands pass', () => {
|
|
38
|
+
assert.equal(runHook({ tool_name: 'Bash', tool_input: { command: 'ls -la' } }).status, 0);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('kubectl rollout restart via the MCP tool is vetoed — the MEASURED engine classifier hole (05 §2.3)', () => {
|
|
42
|
+
for (const toolName of ['mcp__cloudru-vm__k8s_kubectl', 'mcp__cloudru_vm__k8s_kubectl']) {
|
|
43
|
+
const r = runHook({ tool_name: toolName, tool_input: { args: 'rollout restart deployment/app' } });
|
|
44
|
+
assert.equal(r.status, 2, `${toolName} rollout restart must be denied`);
|
|
45
|
+
assert.match(r.stderr, /rollout/);
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('kubectl rollout status|history stay allowed', () => {
|
|
50
|
+
assert.equal(runHook({ tool_name: 'mcp__cloudru-vm__k8s_kubectl', tool_input: { args: 'rollout status deployment/app' } }).status, 0);
|
|
51
|
+
assert.equal(runHook({ tool_name: 'mcp__cloudru-vm__k8s_kubectl', tool_input: { args: 'rollout history deployment/app' } }).status, 0);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('unparseable stdin fails open (a broken hook must not brick every Bash call)', () => {
|
|
55
|
+
const r = spawnSync(process.execPath, [HOOK], { input: 'not json', encoding: 'utf8' });
|
|
56
|
+
assert.equal(r.status, 0);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('--help exits 0 without reading stdin (release smoke contract)', () => {
|
|
60
|
+
const out = execFileSync(process.execPath, [HOOK, '--help'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
61
|
+
assert.match(out, /veto hook/);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('decide() unit: rollout undo denied, config view untouched by this hook', () => {
|
|
65
|
+
assert.ok(decide({ tool_name: 'mcp__cloudru-vm__k8s_kubectl', tool_input: { args: 'rollout undo deployment/app' } }));
|
|
66
|
+
assert.equal(decide({ tool_name: 'mcp__cloudru-vm__k8s_kubectl', tool_input: { args: 'get pods' } }), null);
|
|
67
|
+
});
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// Installer tests — ADR-005 tiering + ADR-004 brake emission with EXECUTED veto.
|
|
2
|
+
import { test } from 'node:test';
|
|
3
|
+
import assert from 'node:assert/strict';
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { createRequire } from 'node:module';
|
|
8
|
+
|
|
9
|
+
const require = createRequire(import.meta.url);
|
|
10
|
+
const { install, mergeSettings, claudeSettingsFragment, probeBrake, HOOK_TEMPLATE, TIERS } = require('../src/install.js');
|
|
11
|
+
const { permissionRules } = require('../src/classification.js');
|
|
12
|
+
|
|
13
|
+
const tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), 'cloudru-hub-test-'));
|
|
14
|
+
|
|
15
|
+
test('claude-code install emits .mcp.json + permission brake + hook, and the veto is EXECUTED', () => {
|
|
16
|
+
const dir = tmp();
|
|
17
|
+
const res = install('claude-code', dir);
|
|
18
|
+
assert.equal(res.code, 0, res.lines.join('\n'));
|
|
19
|
+
|
|
20
|
+
const mcp = JSON.parse(fs.readFileSync(path.join(dir, '.mcp.json'), 'utf8'));
|
|
21
|
+
assert.ok(mcp.mcpServers['cloudru-vm'], 'cloudru-vm server registered');
|
|
22
|
+
|
|
23
|
+
const settings = JSON.parse(fs.readFileSync(path.join(dir, '.claude', 'settings.json'), 'utf8'));
|
|
24
|
+
const rules = permissionRules();
|
|
25
|
+
assert.deepEqual(settings.permissions.ask, rules.ask);
|
|
26
|
+
assert.deepEqual(settings.permissions.deny, rules.deny);
|
|
27
|
+
assert.deepEqual(settings.permissions.allow, rules.allow);
|
|
28
|
+
assert.deepEqual(settings.enabledMcpjsonServers, ['cloudru-vm']);
|
|
29
|
+
assert.ok(settings.hooks.PreToolUse.some((g) => g.matcher === 'Bash'));
|
|
30
|
+
assert.ok(settings.hooks.PreToolUse.some((g) => g.matcher === 'mcp__cloudru[-_]vm__k8s_kubectl'));
|
|
31
|
+
|
|
32
|
+
assert.ok(fs.existsSync(path.join(dir, '.claude', 'hooks', 'cloudru-ssh-guard.cjs')));
|
|
33
|
+
// the installer's own output must show the probe executed and vetoing
|
|
34
|
+
assert.ok(res.lines.some((l) => l.includes('veto EXECUTED')));
|
|
35
|
+
assert.ok(res.lines.some((l) => l.includes('probe sshpass-password-ssh: expected exit 2, got 2 ✓')));
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('every mutating tool of the golden classification is under ask/deny in the emitted settings — none reachable without a human click (ADR-004)', () => {
|
|
39
|
+
const dir = tmp();
|
|
40
|
+
install('claude-code', dir);
|
|
41
|
+
const settings = JSON.parse(fs.readFileSync(path.join(dir, '.claude', 'settings.json'), 'utf8'));
|
|
42
|
+
const guarded = new Set([...settings.permissions.ask, ...settings.permissions.deny]);
|
|
43
|
+
const cls = require('../data/tools-classification.json');
|
|
44
|
+
for (const [name, c] of Object.entries(cls.tools)) {
|
|
45
|
+
if (c.permission === 'allow') continue;
|
|
46
|
+
for (const prefix of ['mcp__cloudru-vm__', 'mcp__cloudru_vm__']) {
|
|
47
|
+
assert.ok(guarded.has(prefix + name), `${prefix}${name} must be ask/deny`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('install merges — existing user settings are preserved, never clobbered', () => {
|
|
53
|
+
const dir = tmp();
|
|
54
|
+
fs.mkdirSync(path.join(dir, '.claude'), { recursive: true });
|
|
55
|
+
fs.writeFileSync(path.join(dir, '.claude', 'settings.json'), JSON.stringify({
|
|
56
|
+
permissions: { allow: ['Bash(git status:*)'], deny: ['WebFetch'] },
|
|
57
|
+
hooks: { Stop: [{ matcher: '*', hooks: [{ type: 'command', command: 'node my-hook.js' }] }] },
|
|
58
|
+
statusLine: { type: 'command', command: 'my-status' },
|
|
59
|
+
}));
|
|
60
|
+
const res = install('claude-code', dir);
|
|
61
|
+
assert.equal(res.code, 0);
|
|
62
|
+
const s = JSON.parse(fs.readFileSync(path.join(dir, '.claude', 'settings.json'), 'utf8'));
|
|
63
|
+
assert.ok(s.permissions.allow.includes('Bash(git status:*)'), 'user allow kept');
|
|
64
|
+
assert.ok(s.permissions.deny.includes('WebFetch'), 'user deny kept');
|
|
65
|
+
assert.ok(s.hooks.Stop.some((g) => g.hooks.some((h) => h.command === 'node my-hook.js')), 'user Stop hook kept');
|
|
66
|
+
assert.equal(s.statusLine.command, 'my-status', 'user statusLine kept');
|
|
67
|
+
assert.ok(s.permissions.deny.includes('mcp__cloudru-vm__secret_value'), 'our deny added');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('install is idempotent — second run does not duplicate rules or hooks', () => {
|
|
71
|
+
const dir = tmp();
|
|
72
|
+
install('claude-code', dir);
|
|
73
|
+
install('claude-code', dir);
|
|
74
|
+
const s = JSON.parse(fs.readFileSync(path.join(dir, '.claude', 'settings.json'), 'utf8'));
|
|
75
|
+
const rules = permissionRules();
|
|
76
|
+
assert.equal(s.permissions.ask.length, rules.ask.length);
|
|
77
|
+
const bashGroups = s.hooks.PreToolUse.filter((g) => g.matcher === 'Bash');
|
|
78
|
+
assert.equal(bashGroups.length, 1);
|
|
79
|
+
assert.equal(bashGroups[0].hooks.length, 1);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('DEGRADED targets are refused with the ADR-005 reason — never a fake install', () => {
|
|
83
|
+
for (const target of ['openclaude', 'opencode', 'cursor', 'windsurf', 'copilot']) {
|
|
84
|
+
const dir = tmp();
|
|
85
|
+
const res = install(target, dir);
|
|
86
|
+
assert.equal(res.code, 2, target);
|
|
87
|
+
assert.ok(res.lines.join('\n').includes('REFUSED'), target);
|
|
88
|
+
assert.ok(res.lines.join('\n').includes('ADR-005'), target);
|
|
89
|
+
assert.equal(fs.readdirSync(dir).length, 0, `${target}: nothing may be written`);
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('POINTER-ONLY targets get the ≤2048-char pointer with the mandatory no-veto warning', () => {
|
|
94
|
+
for (const target of ['agents-md', 'gemini']) {
|
|
95
|
+
const dir = tmp();
|
|
96
|
+
const res = install(target, dir);
|
|
97
|
+
assert.equal(res.code, 0, target);
|
|
98
|
+
const pointer = fs.readFileSync(path.join(dir, 'CLOUDRU-POINTER.md'), 'utf8');
|
|
99
|
+
assert.ok(pointer.length <= 2048, `${target}: pointer is ${pointer.length} chars > 2048 (ADR-006 cap)`);
|
|
100
|
+
assert.ok(Buffer.byteLength(pointer) <= 4096, `${target}: pointer exceeds the 4 KB byte cap (ADR-005)`);
|
|
101
|
+
assert.match(pointer, /ПРЕДУПРЕЖДЕНИЕ/, 'warning line is mandatory');
|
|
102
|
+
assert.match(pointer, /вето|veto/i, 'must warn about the missing veto');
|
|
103
|
+
assert.ok(!/tool_search|hermes cron/.test(pointer), 'pointer carries no Hermes dialect');
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('codex is plan-only: prints the user-global steps + mandatory live probe, applies nothing', () => {
|
|
108
|
+
const dir = tmp();
|
|
109
|
+
const res = install('codex', dir);
|
|
110
|
+
assert.equal(res.code, 3);
|
|
111
|
+
const out = res.lines.join('\n');
|
|
112
|
+
assert.ok(out.includes('codex mcp add'));
|
|
113
|
+
assert.ok(out.includes('~/.codex/hooks.json'));
|
|
114
|
+
assert.ok(out.includes('MANDATORY probe'));
|
|
115
|
+
assert.equal(fs.readdirSync(dir).length, 0, 'nothing written');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('unknown target is an error listing the known ones', () => {
|
|
119
|
+
const res = install('emacs', tmp());
|
|
120
|
+
assert.equal(res.code, 1);
|
|
121
|
+
assert.ok(res.lines[0].includes('unknown target'));
|
|
122
|
+
assert.equal(Object.keys(TIERS).length, 9);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('probeBrake discriminates: a hook that stops vetoing FAILS the probe (broken-brake control)', () => {
|
|
126
|
+
const dir = tmp();
|
|
127
|
+
const brokenHook = path.join(dir, 'broken-guard.cjs');
|
|
128
|
+
// A "hook" that allows everything — the brake with its regexes deleted.
|
|
129
|
+
fs.writeFileSync(brokenHook, '#!/usr/bin/env node\nprocess.exit(0);\n');
|
|
130
|
+
const probe = probeBrake(brokenHook);
|
|
131
|
+
assert.equal(probe.ok, false, 'a pass-everything hook must fail the probe');
|
|
132
|
+
const shouldHaveBlocked = probe.results.filter((r) => r.expected === 2);
|
|
133
|
+
assert.ok(shouldHaveBlocked.every((r) => r.got === 0), 'the paid-step commands got through — and the probe saw it');
|
|
134
|
+
// and the real template passes
|
|
135
|
+
assert.equal(probeBrake(HOOK_TEMPLATE).ok, true);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test('mergeSettings unions permissions and never drops a deny', () => {
|
|
139
|
+
const merged = mergeSettings(
|
|
140
|
+
{ permissions: { deny: ['X'] } },
|
|
141
|
+
claudeSettingsFragment('node hook.cjs'),
|
|
142
|
+
);
|
|
143
|
+
assert.ok(merged.permissions.deny.includes('X'));
|
|
144
|
+
assert.ok(merged.permissions.deny.includes('mcp__cloudru-vm__secret_value'));
|
|
145
|
+
});
|