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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ruflo",
3
- "version": "3.16.2",
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.10.3"
43
+ "@claude-flow/cli": "^3.17.0"
44
44
  },
45
45
  "optionalDependencies": {
46
- "metaharness": "~0.2.6",
46
+ "metaharness": "~0.2.8",
47
47
  "@metaharness/router": "~0.3.2",
48
- "@metaharness/kernel": "~0.1.0",
49
- "@metaharness/darwin": "~0.3.1",
50
- "@metaharness/redblue": "~0.1.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",
@@ -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
- // ---------- CORS middleware ----------
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
- res.setHeader("Access-Control-Allow-Origin", "*");
864
- res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
865
- res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
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
- return res.json({ jsonrpc: "2.0", id, result: {} });
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.json({ jsonrpc: "2.0", id, result: {} });
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
- app.listen(PORT, () => {
1680
- console.log(`MCP Bridge v2.0.0 on port ${PORT}`);
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}
@@ -0,0 +1,6 @@
1
+ {
2
+ "trajectoriesRecorded": 4,
3
+ "patternsLearned": 4,
4
+ "signalsProcessed": 0,
5
+ "lastAdaptation": 1782916325778
6
+ }
@@ -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
- // ---------- CORS middleware ----------
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
- res.setHeader("Access-Control-Allow-Origin", "*");
1039
- res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
1040
- res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
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
- return res.json({ jsonrpc: "2.0", id, result: {} });
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.json({ jsonrpc: "2.0", id, result: {} });
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
- app.listen(PORT, () => {
1890
- console.log(`MCP Bridge v2.0.0 on port ${PORT}`);
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);