ruflo 3.16.2 → 3.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +6 -6
- package/src/mcp-bridge/index.js +97 -9
- package/src/mcp-bridge/test-runtime-security.mjs +208 -0
- package/src/mcp-bridge/test-security-lock.js +98 -0
- package/src/ruvocal/mcp-bridge/.claude-flow/data/pending-insights.jsonl +3 -0
- package/src/ruvocal/mcp-bridge/.claude-flow/neural/stats.json +6 -0
- package/src/ruvocal/mcp-bridge/index.js +102 -9
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ruflo",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.17.0",
|
|
4
4
|
"description": "Ruflo - Enterprise AI agent orchestration platform. Deploy 60+ specialized agents in coordinated swarms with self-learning, fault-tolerant consensus, vector memory, and MCP integration",
|
|
5
5
|
"main": "bin/ruflo.js",
|
|
6
6
|
"type": "module",
|
|
@@ -40,14 +40,14 @@
|
|
|
40
40
|
"package:rvf": "bash src/scripts/package-rvf.sh"
|
|
41
41
|
},
|
|
42
42
|
"dependencies": {
|
|
43
|
-
"@claude-flow/cli": "^3.
|
|
43
|
+
"@claude-flow/cli": "^3.17.0"
|
|
44
44
|
},
|
|
45
45
|
"optionalDependencies": {
|
|
46
|
-
"metaharness": "~0.2.
|
|
46
|
+
"metaharness": "~0.2.8",
|
|
47
47
|
"@metaharness/router": "~0.3.2",
|
|
48
|
-
"@metaharness/kernel": "~0.1.
|
|
49
|
-
"@metaharness/darwin": "~0.
|
|
50
|
-
"@metaharness/redblue": "~0.1.
|
|
48
|
+
"@metaharness/kernel": "~0.1.2",
|
|
49
|
+
"@metaharness/darwin": "~0.8.0",
|
|
50
|
+
"@metaharness/redblue": "~0.1.4"
|
|
51
51
|
},
|
|
52
52
|
"overrides": {
|
|
53
53
|
"ruvector": "^0.2.27",
|
package/src/mcp-bridge/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import express from "express";
|
|
2
2
|
import { spawn } from "child_process";
|
|
3
|
-
import { randomUUID } from "crypto";
|
|
3
|
+
import { randomUUID, timingSafeEqual } from "crypto";
|
|
4
4
|
|
|
5
5
|
// =============================================================================
|
|
6
6
|
// CONFIGURATION
|
|
@@ -12,6 +12,7 @@ const CLOUD_FUNCTIONS = {
|
|
|
12
12
|
};
|
|
13
13
|
|
|
14
14
|
const PORT = parseInt(process.env.PORT || "3001", 10);
|
|
15
|
+
const BIND_HOST = process.env.MCP_BIND_HOST || "127.0.0.1";
|
|
15
16
|
|
|
16
17
|
// =============================================================================
|
|
17
18
|
// TOOL GROUPS — Enable/disable categories of tools independently
|
|
@@ -771,7 +772,30 @@ async function executeGoapSearch(query, args) {
|
|
|
771
772
|
// TOOL EXECUTOR
|
|
772
773
|
// =============================================================================
|
|
773
774
|
|
|
775
|
+
// ADR-166 §6 Phase 1d + 2a — server-side tool gate.
|
|
776
|
+
// Enforced HERE (not just in the autopilot handler) so /mcp, /mcp/:group,
|
|
777
|
+
// autopilot, and any future path share ONE denial gate. Was the missing
|
|
778
|
+
// link that made the disclosed unauthenticated-RCE chain reach shell.
|
|
779
|
+
const DANGEROUS_TOOLS = Object.freeze(new Set([
|
|
780
|
+
"terminal_execute",
|
|
781
|
+
"ruflo__terminal_execute",
|
|
782
|
+
"devtools__terminal_execute",
|
|
783
|
+
]));
|
|
784
|
+
function isTerminalTool(name) {
|
|
785
|
+
return DANGEROUS_TOOLS.has(name) || /terminal_execute/i.test(name);
|
|
786
|
+
}
|
|
787
|
+
const MCP_ENABLE_TERMINAL = process.env.MCP_ENABLE_TERMINAL === "true";
|
|
788
|
+
|
|
774
789
|
async function executeTool(name, args) {
|
|
790
|
+
// Deny dangerous tools unless the operator explicitly opted in.
|
|
791
|
+
// Enforced on every path (not just autopilot) — root cause of ADR-166 V2/V3.
|
|
792
|
+
if (isTerminalTool(name) && !MCP_ENABLE_TERMINAL) {
|
|
793
|
+
return {
|
|
794
|
+
error:
|
|
795
|
+
`Tool "${name}" is disabled by default. Set MCP_ENABLE_TERMINAL=true to allow.`,
|
|
796
|
+
code: "TOOL_DISABLED",
|
|
797
|
+
};
|
|
798
|
+
}
|
|
775
799
|
// Validate that search-like tools have a non-empty query to prevent 400 errors
|
|
776
800
|
if (!args || typeof args !== "object") args = {};
|
|
777
801
|
const rawQuery = args.query ?? args.q ?? args.input ?? "";
|
|
@@ -858,15 +882,52 @@ const GROUP_DISPLAY_NAMES = {
|
|
|
858
882
|
const app = express();
|
|
859
883
|
app.use(express.json({ limit: "10mb" }));
|
|
860
884
|
|
|
861
|
-
// ----------
|
|
885
|
+
// ---------- MCP Streamable HTTP session (#2425 djimit) ----------
|
|
886
|
+
// Streamable-HTTP clients (Codex/RMCP) send `DELETE /mcp` with an
|
|
887
|
+
// `Mcp-Session-Id` header at shutdown. We echo a stable session id back
|
|
888
|
+
// on every /mcp* response so those clients can attach it to the DELETE
|
|
889
|
+
// and to `notifications/initialized` handshakes.
|
|
890
|
+
const MCP_SESSION_ID = randomUUID();
|
|
891
|
+
app.use((req, res, next) => {
|
|
892
|
+
if (req.path.startsWith("/mcp")) {
|
|
893
|
+
res.setHeader("Mcp-Session-Id", MCP_SESSION_ID);
|
|
894
|
+
}
|
|
895
|
+
next();
|
|
896
|
+
});
|
|
897
|
+
|
|
898
|
+
// ---------- CORS middleware (ADR-166 §6 Phase 3b) ----------
|
|
899
|
+
const CORS_ALLOWLIST = (process.env.MCP_CORS_ORIGIN || "*")
|
|
900
|
+
.split(",").map(s => s.trim()).filter(Boolean);
|
|
901
|
+
const CORS_WILDCARD = CORS_ALLOWLIST.length === 1 && CORS_ALLOWLIST[0] === "*";
|
|
862
902
|
app.use((req, res, next) => {
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
903
|
+
const origin = req.get("origin") || "";
|
|
904
|
+
if (CORS_WILDCARD) {
|
|
905
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
906
|
+
} else if (origin && CORS_ALLOWLIST.includes(origin)) {
|
|
907
|
+
res.setHeader("Access-Control-Allow-Origin", origin);
|
|
908
|
+
res.setHeader("Vary", "Origin");
|
|
909
|
+
}
|
|
910
|
+
res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
|
|
911
|
+
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, Mcp-Session-Id");
|
|
866
912
|
if (req.method === "OPTIONS") return res.sendStatus(204);
|
|
867
913
|
next();
|
|
868
914
|
});
|
|
869
915
|
|
|
916
|
+
// ---------- Auth middleware ----------
|
|
917
|
+
// No-op in local-only mode (MCP_AUTH_TOKEN unset). Enforces 401 when token is set.
|
|
918
|
+
const MCP_TOKEN = process.env.MCP_AUTH_TOKEN || "";
|
|
919
|
+
function requireAuth(req, res, next) {
|
|
920
|
+
if (req.path === "/health") return next();
|
|
921
|
+
if (!MCP_TOKEN) return next();
|
|
922
|
+
const expected = `Bearer ${MCP_TOKEN}`;
|
|
923
|
+
const got = req.get("authorization") || "";
|
|
924
|
+
const ok = got.length === expected.length &&
|
|
925
|
+
timingSafeEqual(Buffer.from(got), Buffer.from(expected));
|
|
926
|
+
if (!ok) return res.status(401).json({ error: "unauthorized" });
|
|
927
|
+
next();
|
|
928
|
+
}
|
|
929
|
+
app.use(requireAuth);
|
|
930
|
+
|
|
870
931
|
// ---------- Shared MCP handler ----------
|
|
871
932
|
function createMcpHandler(groupName) {
|
|
872
933
|
return async (req, res) => {
|
|
@@ -896,7 +957,9 @@ function createMcpHandler(groupName) {
|
|
|
896
957
|
return res.json({ jsonrpc: "2.0", id, result: mcpResult });
|
|
897
958
|
}
|
|
898
959
|
case "notifications/initialized":
|
|
899
|
-
|
|
960
|
+
// MCP streamable-HTTP spec: notifications must return 202 Accepted
|
|
961
|
+
// with an empty body (no jsonrpc envelope).
|
|
962
|
+
return res.status(202).end();
|
|
900
963
|
default:
|
|
901
964
|
return res.json({ jsonrpc: "2.0", id, error: { code: -32601, message: `Method not found: ${method}` } });
|
|
902
965
|
}
|
|
@@ -920,6 +983,8 @@ function createMcpSseHandler(groupName) {
|
|
|
920
983
|
for (const groupName of Object.keys(TOOL_GROUPS)) {
|
|
921
984
|
app.post(`/mcp/${groupName}`, createMcpHandler(groupName));
|
|
922
985
|
app.get(`/mcp/${groupName}`, createMcpSseHandler(groupName));
|
|
986
|
+
// #2425 djimit — streamable-HTTP session cleanup
|
|
987
|
+
app.delete(`/mcp/${groupName}`, (_, res) => res.sendStatus(204));
|
|
923
988
|
}
|
|
924
989
|
|
|
925
990
|
// ---------- Catch-all /mcp — serves ALL enabled tools (backwards-compatible) ----------
|
|
@@ -950,7 +1015,7 @@ app.post("/mcp", async (req, res) => {
|
|
|
950
1015
|
return res.json({ jsonrpc: "2.0", id, result: mcpResult });
|
|
951
1016
|
}
|
|
952
1017
|
case "notifications/initialized":
|
|
953
|
-
return res.
|
|
1018
|
+
return res.status(202).end();
|
|
954
1019
|
default:
|
|
955
1020
|
return res.json({ jsonrpc: "2.0", id, error: { code: -32601, message: `Method not found: ${method}` } });
|
|
956
1021
|
}
|
|
@@ -967,6 +1032,9 @@ app.get("/mcp", (req, res) => {
|
|
|
967
1032
|
res.write(`data: ${JSON.stringify({ type: "endpoint", url: "/mcp" })}\n\n`);
|
|
968
1033
|
});
|
|
969
1034
|
|
|
1035
|
+
// #2425 djimit — streamable-HTTP session cleanup on the catch-all route.
|
|
1036
|
+
app.delete("/mcp", (_, res) => res.sendStatus(204));
|
|
1037
|
+
|
|
970
1038
|
// ---------- GET /mcp-servers — returns MCP_SERVERS JSON for Chat UI config ----------
|
|
971
1039
|
app.get("/mcp-servers", (_, res) => {
|
|
972
1040
|
const servers = [];
|
|
@@ -1676,10 +1744,30 @@ app.get("/groups", (_, res) => {
|
|
|
1676
1744
|
// =============================================================================
|
|
1677
1745
|
|
|
1678
1746
|
async function main() {
|
|
1679
|
-
|
|
1680
|
-
|
|
1747
|
+
const isPublic = BIND_HOST !== "127.0.0.1" && BIND_HOST !== "localhost";
|
|
1748
|
+
if (isPublic && !process.env.MCP_AUTH_TOKEN) {
|
|
1749
|
+
console.error(
|
|
1750
|
+
"FATAL: refusing to bind a public interface without MCP_AUTH_TOKEN. " +
|
|
1751
|
+
"Generate one with: MCP_AUTH_TOKEN=$(openssl rand -base64 32)"
|
|
1752
|
+
);
|
|
1753
|
+
process.exit(1);
|
|
1754
|
+
}
|
|
1755
|
+
app.listen(PORT, BIND_HOST, () => {
|
|
1756
|
+
console.log(`MCP Bridge v2.0.0 on port ${PORT} (${BIND_HOST})`);
|
|
1681
1757
|
const enabled = Object.entries(TOOL_GROUPS).filter(([, g]) => g.enabled).map(([n]) => n);
|
|
1682
1758
|
console.log(`Active groups: ${enabled.join(", ")}`);
|
|
1759
|
+
// ADR-166 §6 — startup posture banner
|
|
1760
|
+
console.log(
|
|
1761
|
+
`[security] bind=${BIND_HOST} auth=${process.env.MCP_AUTH_TOKEN ? "bearer" : "off (local-only)"} ` +
|
|
1762
|
+
`terminal=${MCP_ENABLE_TERMINAL ? "ENABLED (⚠ opt-in)" : "disabled"}`,
|
|
1763
|
+
);
|
|
1764
|
+
if (MCP_ENABLE_TERMINAL) {
|
|
1765
|
+
console.warn(
|
|
1766
|
+
"[security] WARNING: terminal_execute is enabled. This tool grants shell access " +
|
|
1767
|
+
"inside the bridge container to any client the auth layer accepts. Ensure " +
|
|
1768
|
+
"MCP_AUTH_TOKEN is set on any non-loopback bind. See ADR-166 §6 Phase 1d.",
|
|
1769
|
+
);
|
|
1770
|
+
}
|
|
1683
1771
|
});
|
|
1684
1772
|
|
|
1685
1773
|
const anyBackendNeeded = BACKEND_DEFS.some(isBackendNeeded);
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* ADR-166 runtime verification — spawn each bridge with a test env and
|
|
4
|
+
* assert the load-bearing behaviors observed OVER THE WIRE. Complements
|
|
5
|
+
* test-security-lock.js (which is static-source only).
|
|
6
|
+
*
|
|
7
|
+
* Behaviors verified:
|
|
8
|
+
* R1. bridge starts on 127.0.0.1 by default
|
|
9
|
+
* R2. with MCP_AUTH_TOKEN set: unauthenticated POST /mcp → 401
|
|
10
|
+
* R3. with MCP_AUTH_TOKEN set: authenticated POST /mcp → 200 (or ≠401)
|
|
11
|
+
* R4. terminal_execute call → TOOL_DISABLED error unless MCP_ENABLE_TERMINAL=true
|
|
12
|
+
* R5. public bind without MCP_AUTH_TOKEN → process exits ≠0 within 3s
|
|
13
|
+
*
|
|
14
|
+
* node test-runtime-security.mjs
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { spawn } from "node:child_process";
|
|
18
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
19
|
+
import path from "node:path";
|
|
20
|
+
import { fileURLToPath } from "node:url";
|
|
21
|
+
|
|
22
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
23
|
+
const BRIDGES = [
|
|
24
|
+
{ label: "src/mcp-bridge", entry: path.join(HERE, "index.js") },
|
|
25
|
+
{ label: "src/ruvocal/mcp-bridge", entry: path.join(HERE, "..", "ruvocal", "mcp-bridge", "index.js") },
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
const TOKEN = "test-token-" + Math.random().toString(36).slice(2);
|
|
29
|
+
let passed = 0, failed = 0;
|
|
30
|
+
const failures = [];
|
|
31
|
+
|
|
32
|
+
function assert(cond, label) {
|
|
33
|
+
if (cond) { console.log(` ✓ ${label}`); passed++; }
|
|
34
|
+
else { console.log(` ✗ ${label}`); failures.push(label); failed++; }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function pickPort() {
|
|
38
|
+
// Pick a random port in the ephemeral range and hope for the best;
|
|
39
|
+
// if the bridge fails to bind, the fetch will time out and we fail.
|
|
40
|
+
return 20000 + Math.floor(Math.random() * 10000);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function startBridge(entry, env, { waitMs = 1500 } = {}) {
|
|
44
|
+
const child = spawn("node", [entry], {
|
|
45
|
+
env: { ...process.env, ...env },
|
|
46
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
47
|
+
});
|
|
48
|
+
let stdout = "", stderr = "";
|
|
49
|
+
child.stdout.on("data", (d) => { stdout += d.toString(); });
|
|
50
|
+
child.stderr.on("data", (d) => { stderr += d.toString(); });
|
|
51
|
+
const exitPromise = new Promise((resolve) => {
|
|
52
|
+
child.on("exit", (code) => resolve({ exited: true, code }));
|
|
53
|
+
});
|
|
54
|
+
const ready = Promise.race([
|
|
55
|
+
exitPromise,
|
|
56
|
+
sleep(waitMs).then(() => ({ exited: false, code: null })),
|
|
57
|
+
]);
|
|
58
|
+
return { child, stdout: () => stdout, stderr: () => stderr, ready };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function killAndWait(child) {
|
|
62
|
+
if (child.exitCode !== null) return;
|
|
63
|
+
try { child.kill("SIGTERM"); } catch { /* ignore */ }
|
|
64
|
+
await sleep(100);
|
|
65
|
+
if (child.exitCode === null) try { child.kill("SIGKILL"); } catch { /* ignore */ }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function testBridge(label, entry) {
|
|
69
|
+
console.log(`\n# ${label}`);
|
|
70
|
+
|
|
71
|
+
// R1 + R2 + R3 + R4 — bridge starts with token, auth behavior + terminal gate
|
|
72
|
+
{
|
|
73
|
+
const port = await pickPort();
|
|
74
|
+
const b = await startBridge(entry, {
|
|
75
|
+
PORT: String(port),
|
|
76
|
+
MCP_BIND_HOST: "127.0.0.1",
|
|
77
|
+
MCP_AUTH_TOKEN: TOKEN,
|
|
78
|
+
MCP_ENABLE_TERMINAL: "false",
|
|
79
|
+
MCP_GROUP_DEVTOOLS: "false",
|
|
80
|
+
MCP_GROUP_INTELLIGENCE: "false",
|
|
81
|
+
MCP_GROUP_AGENTS: "false",
|
|
82
|
+
MCP_GROUP_MEMORY: "false",
|
|
83
|
+
});
|
|
84
|
+
const state = await b.ready;
|
|
85
|
+
if (state.exited) {
|
|
86
|
+
assert(false, `R1. bridge starts on 127.0.0.1 (exited early, code=${state.code}; stderr=${b.stderr().slice(0, 200)})`);
|
|
87
|
+
} else {
|
|
88
|
+
assert(true, "R1. bridge starts on 127.0.0.1 (didn't exit within 1.5s)");
|
|
89
|
+
|
|
90
|
+
// R2 — unauthenticated POST /mcp → 401
|
|
91
|
+
try {
|
|
92
|
+
const r = await fetch(`http://127.0.0.1:${port}/mcp`, {
|
|
93
|
+
method: "POST",
|
|
94
|
+
headers: { "Content-Type": "application/json" },
|
|
95
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }),
|
|
96
|
+
});
|
|
97
|
+
assert(r.status === 401, `R2. unauthenticated POST /mcp → 401 (got ${r.status})`);
|
|
98
|
+
} catch (e) {
|
|
99
|
+
assert(false, `R2. unauthenticated POST /mcp threw: ${e.message}`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// R3 — authenticated POST /mcp → not 401
|
|
103
|
+
try {
|
|
104
|
+
const r = await fetch(`http://127.0.0.1:${port}/mcp`, {
|
|
105
|
+
method: "POST",
|
|
106
|
+
headers: {
|
|
107
|
+
"Content-Type": "application/json",
|
|
108
|
+
"Authorization": `Bearer ${TOKEN}`,
|
|
109
|
+
},
|
|
110
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }),
|
|
111
|
+
});
|
|
112
|
+
assert(r.status !== 401, `R3. authenticated POST /mcp → ≠401 (got ${r.status})`);
|
|
113
|
+
} catch (e) {
|
|
114
|
+
assert(false, `R3. authenticated POST /mcp threw: ${e.message}`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// R4 — terminal_execute → TOOL_DISABLED (denied at executeTool, not just autopilot)
|
|
118
|
+
try {
|
|
119
|
+
const r = await fetch(`http://127.0.0.1:${port}/mcp`, {
|
|
120
|
+
method: "POST",
|
|
121
|
+
headers: {
|
|
122
|
+
"Content-Type": "application/json",
|
|
123
|
+
"Authorization": `Bearer ${TOKEN}`,
|
|
124
|
+
},
|
|
125
|
+
body: JSON.stringify({
|
|
126
|
+
jsonrpc: "2.0", id: 1, method: "tools/call",
|
|
127
|
+
params: { name: "terminal_execute", arguments: { command: "id" } },
|
|
128
|
+
}),
|
|
129
|
+
});
|
|
130
|
+
const body = await r.json();
|
|
131
|
+
// The response wraps the executeTool result in `result.content[0].text` as JSON string.
|
|
132
|
+
const text = JSON.stringify(body);
|
|
133
|
+
assert(text.includes("TOOL_DISABLED") || text.includes("disabled by default"),
|
|
134
|
+
"R4. terminal_execute denied server-side (TOOL_DISABLED)");
|
|
135
|
+
} catch (e) {
|
|
136
|
+
assert(false, `R4. terminal_execute threw: ${e.message}`);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// R6 — #2425 djimit streamable-HTTP session cleanup: DELETE /mcp → 204
|
|
140
|
+
try {
|
|
141
|
+
const r = await fetch(`http://127.0.0.1:${port}/mcp`, {
|
|
142
|
+
method: "DELETE",
|
|
143
|
+
headers: {
|
|
144
|
+
"Authorization": `Bearer ${TOKEN}`,
|
|
145
|
+
"Mcp-Session-Id": "test-cleanup",
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
assert(r.status === 204, `R6. DELETE /mcp → 204 (got ${r.status})`);
|
|
149
|
+
assert(!!r.headers.get("Mcp-Session-Id"), "R6b. Mcp-Session-Id header echoed on /mcp*");
|
|
150
|
+
} catch (e) {
|
|
151
|
+
assert(false, `R6. DELETE /mcp threw: ${e.message}`);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// R7 — #2425 notifications/initialized returns 202 Accepted with empty body
|
|
155
|
+
try {
|
|
156
|
+
const r = await fetch(`http://127.0.0.1:${port}/mcp`, {
|
|
157
|
+
method: "POST",
|
|
158
|
+
headers: {
|
|
159
|
+
"Content-Type": "application/json",
|
|
160
|
+
"Authorization": `Bearer ${TOKEN}`,
|
|
161
|
+
},
|
|
162
|
+
body: JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" }),
|
|
163
|
+
});
|
|
164
|
+
assert(r.status === 202, `R7. notifications/initialized → 202 (got ${r.status})`);
|
|
165
|
+
assert((await r.text()) === "", "R7b. notifications/initialized has empty body");
|
|
166
|
+
} catch (e) {
|
|
167
|
+
assert(false, `R7. notifications/initialized threw: ${e.message}`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
await killAndWait(b.child);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// R5 — public bind without token → process exits ≠0 within 3s
|
|
174
|
+
{
|
|
175
|
+
const port = await pickPort();
|
|
176
|
+
const b = await startBridge(entry, {
|
|
177
|
+
PORT: String(port),
|
|
178
|
+
MCP_BIND_HOST: "0.0.0.0",
|
|
179
|
+
MCP_AUTH_TOKEN: "",
|
|
180
|
+
MCP_GROUP_DEVTOOLS: "false",
|
|
181
|
+
MCP_GROUP_INTELLIGENCE: "false",
|
|
182
|
+
MCP_GROUP_AGENTS: "false",
|
|
183
|
+
MCP_GROUP_MEMORY: "false",
|
|
184
|
+
}, { waitMs: 3000 });
|
|
185
|
+
const state = await b.ready;
|
|
186
|
+
assert(state.exited && state.code !== 0,
|
|
187
|
+
`R5. public bind without token exits ≠0 (exited=${state.exited}, code=${state.code})`);
|
|
188
|
+
if (b.stderr().includes("FATAL")) {
|
|
189
|
+
assert(true, "R5b. FATAL message logged to stderr");
|
|
190
|
+
} else {
|
|
191
|
+
assert(false, "R5b. FATAL message expected in stderr");
|
|
192
|
+
}
|
|
193
|
+
await killAndWait(b.child);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
(async () => {
|
|
198
|
+
for (const { label, entry } of BRIDGES) {
|
|
199
|
+
await testBridge(label, entry);
|
|
200
|
+
}
|
|
201
|
+
console.log(`\n${passed} passed, ${failed} failed`);
|
|
202
|
+
if (failed > 0) {
|
|
203
|
+
console.log("\nFailures:");
|
|
204
|
+
for (const f of failures) console.log(` - ${f}`);
|
|
205
|
+
process.exit(1);
|
|
206
|
+
}
|
|
207
|
+
console.log("ADR-166 runtime verification: OK");
|
|
208
|
+
})();
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* ADR-166 §6 acceptance #9 — anti-regression lock for the MCP-bridge
|
|
4
|
+
* security surface. Runs on the source of BOTH bridge files (the ruvocal
|
|
5
|
+
* variant that ships in docker-compose, and the sibling REST bridge).
|
|
6
|
+
*
|
|
7
|
+
* Fails if any of the following load-bearing controls silently disappears:
|
|
8
|
+
* 1. `MCP_BIND_HOST` default is `127.0.0.1` (loopback default — Phase 1a)
|
|
9
|
+
* 2. Public bind without `MCP_AUTH_TOKEN` calls `process.exit(1)` at boot (Phase 1b)
|
|
10
|
+
* 3. `requireAuth` middleware exists and is mounted with `app.use(requireAuth)` (Phase 1c)
|
|
11
|
+
* 4. `requireAuth` uses `timingSafeEqual` (constant-time compare)
|
|
12
|
+
* 5. `executeTool` denies terminal_execute unless `MCP_ENABLE_TERMINAL === "true"` (Phase 1d + 2a)
|
|
13
|
+
* 6. CORS respects `MCP_CORS_ORIGIN` allowlist (Phase 3b)
|
|
14
|
+
*
|
|
15
|
+
* Runs offline (no server, no deps). CI-safe.
|
|
16
|
+
*
|
|
17
|
+
* node test-security-lock.js # → exit 0 on green, exit 1 on regression
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import fs from "node:fs";
|
|
21
|
+
import path from "node:path";
|
|
22
|
+
import { fileURLToPath } from "node:url";
|
|
23
|
+
|
|
24
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
25
|
+
const BRIDGES = [
|
|
26
|
+
path.join(HERE, "index.js"), // ruflo/src/mcp-bridge/index.js
|
|
27
|
+
path.join(HERE, "..", "ruvocal", "mcp-bridge", "index.js"), // ruvocal variant (deployed)
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
const CHECKS = [
|
|
31
|
+
{
|
|
32
|
+
id: "1. bind-loopback-default",
|
|
33
|
+
rx: /BIND_HOST\s*=\s*process\.env\.MCP_BIND_HOST\s*\|\|\s*["']127\.0\.0\.1["']/,
|
|
34
|
+
hint: "MCP_BIND_HOST must default to 127.0.0.1 (ADR-166 §6 Phase 1a)",
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
id: "2. fail-closed-public-bind",
|
|
38
|
+
rx: /isPublic\s*&&\s*!process\.env\.MCP_AUTH_TOKEN[\s\S]*?process\.exit\(\s*1\s*\)/,
|
|
39
|
+
hint: "public bind without MCP_AUTH_TOKEN must process.exit(1) at boot (Phase 1b)",
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
id: "3. auth-middleware-mounted",
|
|
43
|
+
rx: /app\.use\(\s*requireAuth\s*\)/,
|
|
44
|
+
hint: "requireAuth middleware must be mounted via app.use() (Phase 1c)",
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
id: "4. constant-time-token-compare",
|
|
48
|
+
rx: /timingSafeEqual\s*\(/,
|
|
49
|
+
hint: "auth compare must use timingSafeEqual (Phase 1c)",
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
id: "5. terminal-gate-server-side",
|
|
53
|
+
rx: /MCP_ENABLE_TERMINAL[\s\S]*?executeTool[\s\S]*?TOOL_DISABLED|executeTool[\s\S]*?MCP_ENABLE_TERMINAL[\s\S]*?TOOL_DISABLED/,
|
|
54
|
+
hint: "terminal_execute must be denied at executeTool unless MCP_ENABLE_TERMINAL=true (Phase 1d + 2a)",
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
id: "6. cors-allowlist-respected",
|
|
58
|
+
rx: /MCP_CORS_ORIGIN[\s\S]*?CORS_ALLOWLIST/,
|
|
59
|
+
hint: "CORS must respect MCP_CORS_ORIGIN allowlist (Phase 3b)",
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
id: "7. streamable-http-delete-handler (#2425)",
|
|
63
|
+
rx: /app\.delete\(\s*["']\/mcp["']\s*,/,
|
|
64
|
+
hint: "DELETE /mcp must be handled for streamable-HTTP session cleanup (#2425)",
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
id: "8. mcp-session-id-header (#2425)",
|
|
68
|
+
rx: /Mcp-Session-Id/,
|
|
69
|
+
hint: "Mcp-Session-Id header must be echoed on /mcp* responses (#2425)",
|
|
70
|
+
},
|
|
71
|
+
];
|
|
72
|
+
|
|
73
|
+
let hardFail = false;
|
|
74
|
+
for (const bridge of BRIDGES) {
|
|
75
|
+
if (!fs.existsSync(bridge)) {
|
|
76
|
+
console.error(`FAIL: bridge missing: ${bridge}`);
|
|
77
|
+
hardFail = true;
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
const src = fs.readFileSync(bridge, "utf-8");
|
|
81
|
+
const rel = path.relative(path.join(HERE, "..", ".."), bridge);
|
|
82
|
+
console.log(`\n# ${rel}`);
|
|
83
|
+
for (const check of CHECKS) {
|
|
84
|
+
if (check.rx.test(src)) {
|
|
85
|
+
console.log(` ✓ ${check.id}`);
|
|
86
|
+
} else {
|
|
87
|
+
console.log(` ✗ ${check.id}`);
|
|
88
|
+
console.log(` hint: ${check.hint}`);
|
|
89
|
+
hardFail = true;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (hardFail) {
|
|
95
|
+
console.error("\nADR-166 security lock FAILED — a load-bearing control was removed or drifted.");
|
|
96
|
+
process.exit(1);
|
|
97
|
+
}
|
|
98
|
+
console.log("\nADR-166 security lock: OK");
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
{"type":"edit","file":"/Users/cohen/Projects/ruflo/.github/workflows/adr-166-mcp-bridge-security.yml","timestamp":1782913295523}
|
|
2
|
+
{"type":"edit","file":"/Users/cohen/Projects/ruflo/v3/docs/adr/ADR-166-mcp-bridge-unauthenticated-rce-remediation.md","timestamp":1782913317258}
|
|
3
|
+
{"type":"edit","file":"/Users/cohen/Projects/ruflo/v3/docs/adr/ADR-166-mcp-bridge-unauthenticated-rce-remediation.md","timestamp":1782913344234}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import express from "express";
|
|
2
2
|
import { spawn } from "child_process";
|
|
3
|
-
import { randomUUID } from "crypto";
|
|
3
|
+
import { randomUUID, timingSafeEqual } from "crypto";
|
|
4
4
|
|
|
5
5
|
// =============================================================================
|
|
6
6
|
// CONFIGURATION
|
|
@@ -12,6 +12,7 @@ const CLOUD_FUNCTIONS = {
|
|
|
12
12
|
};
|
|
13
13
|
|
|
14
14
|
const PORT = parseInt(process.env.PORT || "3001", 10);
|
|
15
|
+
const BIND_HOST = process.env.MCP_BIND_HOST || "127.0.0.1";
|
|
15
16
|
|
|
16
17
|
// =============================================================================
|
|
17
18
|
// TOOL GROUPS — Enable/disable categories of tools independently
|
|
@@ -942,7 +943,30 @@ async function executeGoapSearchGemini(query, args) {
|
|
|
942
943
|
// TOOL EXECUTOR
|
|
943
944
|
// =============================================================================
|
|
944
945
|
|
|
946
|
+
// ADR-166 §6 Phase 1d + 2a — server-side tool gate.
|
|
947
|
+
// Enforced HERE (not just in the autopilot handler) so /mcp, /mcp/:group,
|
|
948
|
+
// autopilot, and any future path share ONE denial gate. Was the missing
|
|
949
|
+
// link that made the disclosed unauthenticated-RCE chain reach shell.
|
|
950
|
+
const DANGEROUS_TOOLS = Object.freeze(new Set([
|
|
951
|
+
"terminal_execute",
|
|
952
|
+
"ruflo__terminal_execute",
|
|
953
|
+
"devtools__terminal_execute",
|
|
954
|
+
]));
|
|
955
|
+
function isTerminalTool(name) {
|
|
956
|
+
return DANGEROUS_TOOLS.has(name) || /terminal_execute/i.test(name);
|
|
957
|
+
}
|
|
958
|
+
const MCP_ENABLE_TERMINAL = process.env.MCP_ENABLE_TERMINAL === "true";
|
|
959
|
+
|
|
945
960
|
async function executeTool(name, args) {
|
|
961
|
+
// Deny dangerous tools unless the operator explicitly opted in.
|
|
962
|
+
// Enforced on every path (not just autopilot) — root cause of ADR-166 V2/V3.
|
|
963
|
+
if (isTerminalTool(name) && !MCP_ENABLE_TERMINAL) {
|
|
964
|
+
return {
|
|
965
|
+
error:
|
|
966
|
+
`Tool "${name}" is disabled by default. Set MCP_ENABLE_TERMINAL=true to allow.`,
|
|
967
|
+
code: "TOOL_DISABLED",
|
|
968
|
+
};
|
|
969
|
+
}
|
|
946
970
|
switch (name) {
|
|
947
971
|
case "search": {
|
|
948
972
|
if (CLOUD_FUNCTIONS.search) {
|
|
@@ -1033,15 +1057,57 @@ const GROUP_DISPLAY_NAMES = {
|
|
|
1033
1057
|
const app = express();
|
|
1034
1058
|
app.use(express.json({ limit: "10mb" }));
|
|
1035
1059
|
|
|
1036
|
-
// ----------
|
|
1060
|
+
// ---------- MCP Streamable HTTP session (#2425 djimit) ----------
|
|
1061
|
+
// Streamable-HTTP clients (Codex/RMCP) send `DELETE /mcp` with an
|
|
1062
|
+
// `Mcp-Session-Id` header at shutdown. We echo a stable session id back
|
|
1063
|
+
// on every /mcp* response so those clients can attach it to the DELETE
|
|
1064
|
+
// and to `notifications/initialized` handshakes.
|
|
1065
|
+
const MCP_SESSION_ID = randomUUID();
|
|
1066
|
+
app.use((req, res, next) => {
|
|
1067
|
+
if (req.path.startsWith("/mcp")) {
|
|
1068
|
+
res.setHeader("Mcp-Session-Id", MCP_SESSION_ID);
|
|
1069
|
+
}
|
|
1070
|
+
next();
|
|
1071
|
+
});
|
|
1072
|
+
|
|
1073
|
+
// ---------- CORS middleware (ADR-166 §6 Phase 3b) ----------
|
|
1074
|
+
// MCP_CORS_ORIGIN: comma-separated allowlist (e.g. "https://a.example,https://b.example").
|
|
1075
|
+
// unset → "*" for back-compat (loopback default is same-origin anyway)
|
|
1076
|
+
// "*" → wildcard (explicit opt-in — same as legacy behavior)
|
|
1077
|
+
// other → echo the request origin ONLY if it appears in the allowlist
|
|
1078
|
+
const CORS_ALLOWLIST = (process.env.MCP_CORS_ORIGIN || "*")
|
|
1079
|
+
.split(",").map(s => s.trim()).filter(Boolean);
|
|
1080
|
+
const CORS_WILDCARD = CORS_ALLOWLIST.length === 1 && CORS_ALLOWLIST[0] === "*";
|
|
1037
1081
|
app.use((req, res, next) => {
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1082
|
+
const origin = req.get("origin") || "";
|
|
1083
|
+
if (CORS_WILDCARD) {
|
|
1084
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
1085
|
+
} else if (origin && CORS_ALLOWLIST.includes(origin)) {
|
|
1086
|
+
res.setHeader("Access-Control-Allow-Origin", origin);
|
|
1087
|
+
res.setHeader("Vary", "Origin");
|
|
1088
|
+
}
|
|
1089
|
+
// If no match, do NOT set the header — browser will block the request.
|
|
1090
|
+
res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
|
|
1091
|
+
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, Mcp-Session-Id");
|
|
1041
1092
|
if (req.method === "OPTIONS") return res.sendStatus(204);
|
|
1042
1093
|
next();
|
|
1043
1094
|
});
|
|
1044
1095
|
|
|
1096
|
+
// ---------- Auth middleware ----------
|
|
1097
|
+
// No-op in local-only mode (MCP_AUTH_TOKEN unset). Enforces 401 when token is set.
|
|
1098
|
+
const MCP_TOKEN = process.env.MCP_AUTH_TOKEN || "";
|
|
1099
|
+
function requireAuth(req, res, next) {
|
|
1100
|
+
if (req.path === "/health") return next();
|
|
1101
|
+
if (!MCP_TOKEN) return next();
|
|
1102
|
+
const expected = `Bearer ${MCP_TOKEN}`;
|
|
1103
|
+
const got = req.get("authorization") || "";
|
|
1104
|
+
const ok = got.length === expected.length &&
|
|
1105
|
+
timingSafeEqual(Buffer.from(got), Buffer.from(expected));
|
|
1106
|
+
if (!ok) return res.status(401).json({ error: "unauthorized" });
|
|
1107
|
+
next();
|
|
1108
|
+
}
|
|
1109
|
+
app.use(requireAuth);
|
|
1110
|
+
|
|
1045
1111
|
// ---------- Shared MCP handler ----------
|
|
1046
1112
|
function createMcpHandler(groupName) {
|
|
1047
1113
|
return async (req, res) => {
|
|
@@ -1072,7 +1138,9 @@ function createMcpHandler(groupName) {
|
|
|
1072
1138
|
});
|
|
1073
1139
|
}
|
|
1074
1140
|
case "notifications/initialized":
|
|
1075
|
-
|
|
1141
|
+
// MCP streamable-HTTP spec: notifications must return 202 Accepted
|
|
1142
|
+
// with an empty body (no jsonrpc envelope).
|
|
1143
|
+
return res.status(202).end();
|
|
1076
1144
|
default:
|
|
1077
1145
|
return res.json({ jsonrpc: "2.0", id, error: { code: -32601, message: `Method not found: ${method}` } });
|
|
1078
1146
|
}
|
|
@@ -1096,6 +1164,8 @@ function createMcpSseHandler(groupName) {
|
|
|
1096
1164
|
for (const groupName of Object.keys(TOOL_GROUPS)) {
|
|
1097
1165
|
app.post(`/mcp/${groupName}`, createMcpHandler(groupName));
|
|
1098
1166
|
app.get(`/mcp/${groupName}`, createMcpSseHandler(groupName));
|
|
1167
|
+
// #2425 djimit — streamable-HTTP session cleanup
|
|
1168
|
+
app.delete(`/mcp/${groupName}`, (_, res) => res.sendStatus(204));
|
|
1099
1169
|
}
|
|
1100
1170
|
|
|
1101
1171
|
// ---------- Catch-all /mcp — serves ALL enabled tools (backwards-compatible) ----------
|
|
@@ -1127,7 +1197,7 @@ app.post("/mcp", async (req, res) => {
|
|
|
1127
1197
|
});
|
|
1128
1198
|
}
|
|
1129
1199
|
case "notifications/initialized":
|
|
1130
|
-
return res.
|
|
1200
|
+
return res.status(202).end();
|
|
1131
1201
|
default:
|
|
1132
1202
|
return res.json({ jsonrpc: "2.0", id, error: { code: -32601, message: `Method not found: ${method}` } });
|
|
1133
1203
|
}
|
|
@@ -1144,6 +1214,9 @@ app.get("/mcp", (req, res) => {
|
|
|
1144
1214
|
res.write(`data: ${JSON.stringify({ type: "endpoint", url: "/mcp" })}\n\n`);
|
|
1145
1215
|
});
|
|
1146
1216
|
|
|
1217
|
+
// #2425 djimit — streamable-HTTP session cleanup on the catch-all route.
|
|
1218
|
+
app.delete("/mcp", (_, res) => res.sendStatus(204));
|
|
1219
|
+
|
|
1147
1220
|
// ---------- GET /mcp-servers — returns MCP_SERVERS JSON for Chat UI config ----------
|
|
1148
1221
|
app.get("/mcp-servers", (_, res) => {
|
|
1149
1222
|
const servers = [];
|
|
@@ -1886,10 +1959,30 @@ app.get("/groups", (_, res) => {
|
|
|
1886
1959
|
// =============================================================================
|
|
1887
1960
|
|
|
1888
1961
|
async function main() {
|
|
1889
|
-
|
|
1890
|
-
|
|
1962
|
+
const isPublic = BIND_HOST !== "127.0.0.1" && BIND_HOST !== "localhost";
|
|
1963
|
+
if (isPublic && !process.env.MCP_AUTH_TOKEN) {
|
|
1964
|
+
console.error(
|
|
1965
|
+
"FATAL: refusing to bind a public interface without MCP_AUTH_TOKEN. " +
|
|
1966
|
+
"Generate one with: MCP_AUTH_TOKEN=$(openssl rand -base64 32)"
|
|
1967
|
+
);
|
|
1968
|
+
process.exit(1);
|
|
1969
|
+
}
|
|
1970
|
+
app.listen(PORT, BIND_HOST, () => {
|
|
1971
|
+
console.log(`MCP Bridge v2.0.0 on port ${PORT} (${BIND_HOST})`);
|
|
1891
1972
|
const enabled = Object.entries(TOOL_GROUPS).filter(([, g]) => g.enabled).map(([n]) => n);
|
|
1892
1973
|
console.log(`Active groups: ${enabled.join(", ")}`);
|
|
1974
|
+
// ADR-166 §6 — startup posture banner (helps operators see the security state at boot)
|
|
1975
|
+
console.log(
|
|
1976
|
+
`[security] bind=${BIND_HOST} auth=${process.env.MCP_AUTH_TOKEN ? "bearer" : "off (local-only)"} ` +
|
|
1977
|
+
`terminal=${MCP_ENABLE_TERMINAL ? "ENABLED (⚠ opt-in)" : "disabled"}`,
|
|
1978
|
+
);
|
|
1979
|
+
if (MCP_ENABLE_TERMINAL) {
|
|
1980
|
+
console.warn(
|
|
1981
|
+
"[security] WARNING: terminal_execute is enabled. This tool grants shell access " +
|
|
1982
|
+
"inside the bridge container to any client the auth layer accepts. Ensure " +
|
|
1983
|
+
"MCP_AUTH_TOKEN is set on any non-loopback bind. See ADR-166 §6 Phase 1d.",
|
|
1984
|
+
);
|
|
1985
|
+
}
|
|
1893
1986
|
});
|
|
1894
1987
|
|
|
1895
1988
|
const anyBackendNeeded = BACKEND_DEFS.some(isBackendNeeded);
|