ruflo 3.16.1 → 3.16.3

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.1",
3
+ "version": "3.16.3",
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",
@@ -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,39 @@ const GROUP_DISPLAY_NAMES = {
858
882
  const app = express();
859
883
  app.use(express.json({ limit: "10mb" }));
860
884
 
861
- // ---------- CORS middleware ----------
885
+ // ---------- CORS middleware (ADR-166 §6 Phase 3b) ----------
886
+ const CORS_ALLOWLIST = (process.env.MCP_CORS_ORIGIN || "*")
887
+ .split(",").map(s => s.trim()).filter(Boolean);
888
+ const CORS_WILDCARD = CORS_ALLOWLIST.length === 1 && CORS_ALLOWLIST[0] === "*";
862
889
  app.use((req, res, next) => {
863
- res.setHeader("Access-Control-Allow-Origin", "*");
890
+ const origin = req.get("origin") || "";
891
+ if (CORS_WILDCARD) {
892
+ res.setHeader("Access-Control-Allow-Origin", "*");
893
+ } else if (origin && CORS_ALLOWLIST.includes(origin)) {
894
+ res.setHeader("Access-Control-Allow-Origin", origin);
895
+ res.setHeader("Vary", "Origin");
896
+ }
864
897
  res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
865
898
  res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
866
899
  if (req.method === "OPTIONS") return res.sendStatus(204);
867
900
  next();
868
901
  });
869
902
 
903
+ // ---------- Auth middleware ----------
904
+ // No-op in local-only mode (MCP_AUTH_TOKEN unset). Enforces 401 when token is set.
905
+ const MCP_TOKEN = process.env.MCP_AUTH_TOKEN || "";
906
+ function requireAuth(req, res, next) {
907
+ if (req.path === "/health") return next();
908
+ if (!MCP_TOKEN) return next();
909
+ const expected = `Bearer ${MCP_TOKEN}`;
910
+ const got = req.get("authorization") || "";
911
+ const ok = got.length === expected.length &&
912
+ timingSafeEqual(Buffer.from(got), Buffer.from(expected));
913
+ if (!ok) return res.status(401).json({ error: "unauthorized" });
914
+ next();
915
+ }
916
+ app.use(requireAuth);
917
+
870
918
  // ---------- Shared MCP handler ----------
871
919
  function createMcpHandler(groupName) {
872
920
  return async (req, res) => {
@@ -1676,10 +1724,30 @@ app.get("/groups", (_, res) => {
1676
1724
  // =============================================================================
1677
1725
 
1678
1726
  async function main() {
1679
- app.listen(PORT, () => {
1680
- console.log(`MCP Bridge v2.0.0 on port ${PORT}`);
1727
+ const isPublic = BIND_HOST !== "127.0.0.1" && BIND_HOST !== "localhost";
1728
+ if (isPublic && !process.env.MCP_AUTH_TOKEN) {
1729
+ console.error(
1730
+ "FATAL: refusing to bind a public interface without MCP_AUTH_TOKEN. " +
1731
+ "Generate one with: MCP_AUTH_TOKEN=$(openssl rand -base64 32)"
1732
+ );
1733
+ process.exit(1);
1734
+ }
1735
+ app.listen(PORT, BIND_HOST, () => {
1736
+ console.log(`MCP Bridge v2.0.0 on port ${PORT} (${BIND_HOST})`);
1681
1737
  const enabled = Object.entries(TOOL_GROUPS).filter(([, g]) => g.enabled).map(([n]) => n);
1682
1738
  console.log(`Active groups: ${enabled.join(", ")}`);
1739
+ // ADR-166 §6 — startup posture banner
1740
+ console.log(
1741
+ `[security] bind=${BIND_HOST} auth=${process.env.MCP_AUTH_TOKEN ? "bearer" : "off (local-only)"} ` +
1742
+ `terminal=${MCP_ENABLE_TERMINAL ? "ENABLED (⚠ opt-in)" : "disabled"}`,
1743
+ );
1744
+ if (MCP_ENABLE_TERMINAL) {
1745
+ console.warn(
1746
+ "[security] WARNING: terminal_execute is enabled. This tool grants shell access " +
1747
+ "inside the bridge container to any client the auth layer accepts. Ensure " +
1748
+ "MCP_AUTH_TOKEN is set on any non-loopback bind. See ADR-166 §6 Phase 1d.",
1749
+ );
1750
+ }
1683
1751
  });
1684
1752
 
1685
1753
  const anyBackendNeeded = BACKEND_DEFS.some(isBackendNeeded);
@@ -0,0 +1,177 @@
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
+ await killAndWait(b.child);
140
+ }
141
+
142
+ // R5 — public bind without token → process exits ≠0 within 3s
143
+ {
144
+ const port = await pickPort();
145
+ const b = await startBridge(entry, {
146
+ PORT: String(port),
147
+ MCP_BIND_HOST: "0.0.0.0",
148
+ MCP_AUTH_TOKEN: "",
149
+ MCP_GROUP_DEVTOOLS: "false",
150
+ MCP_GROUP_INTELLIGENCE: "false",
151
+ MCP_GROUP_AGENTS: "false",
152
+ MCP_GROUP_MEMORY: "false",
153
+ }, { waitMs: 3000 });
154
+ const state = await b.ready;
155
+ assert(state.exited && state.code !== 0,
156
+ `R5. public bind without token exits ≠0 (exited=${state.exited}, code=${state.code})`);
157
+ if (b.stderr().includes("FATAL")) {
158
+ assert(true, "R5b. FATAL message logged to stderr");
159
+ } else {
160
+ assert(false, "R5b. FATAL message expected in stderr");
161
+ }
162
+ await killAndWait(b.child);
163
+ }
164
+ }
165
+
166
+ (async () => {
167
+ for (const { label, entry } of BRIDGES) {
168
+ await testBridge(label, entry);
169
+ }
170
+ console.log(`\n${passed} passed, ${failed} failed`);
171
+ if (failed > 0) {
172
+ console.log("\nFailures:");
173
+ for (const f of failures) console.log(` - ${f}`);
174
+ process.exit(1);
175
+ }
176
+ console.log("ADR-166 runtime verification: OK");
177
+ })();
@@ -0,0 +1,88 @@
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
+
63
+ let hardFail = false;
64
+ for (const bridge of BRIDGES) {
65
+ if (!fs.existsSync(bridge)) {
66
+ console.error(`FAIL: bridge missing: ${bridge}`);
67
+ hardFail = true;
68
+ continue;
69
+ }
70
+ const src = fs.readFileSync(bridge, "utf-8");
71
+ const rel = path.relative(path.join(HERE, "..", ".."), bridge);
72
+ console.log(`\n# ${rel}`);
73
+ for (const check of CHECKS) {
74
+ if (check.rx.test(src)) {
75
+ console.log(` ✓ ${check.id}`);
76
+ } else {
77
+ console.log(` ✗ ${check.id}`);
78
+ console.log(` hint: ${check.hint}`);
79
+ hardFail = true;
80
+ }
81
+ }
82
+ }
83
+
84
+ if (hardFail) {
85
+ console.error("\nADR-166 security lock FAILED — a load-bearing control was removed or drifted.");
86
+ process.exit(1);
87
+ }
88
+ 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,44 @@ const GROUP_DISPLAY_NAMES = {
1033
1057
  const app = express();
1034
1058
  app.use(express.json({ limit: "10mb" }));
1035
1059
 
1036
- // ---------- CORS middleware ----------
1060
+ // ---------- CORS middleware (ADR-166 §6 Phase 3b) ----------
1061
+ // MCP_CORS_ORIGIN: comma-separated allowlist (e.g. "https://a.example,https://b.example").
1062
+ // unset → "*" for back-compat (loopback default is same-origin anyway)
1063
+ // "*" → wildcard (explicit opt-in — same as legacy behavior)
1064
+ // other → echo the request origin ONLY if it appears in the allowlist
1065
+ const CORS_ALLOWLIST = (process.env.MCP_CORS_ORIGIN || "*")
1066
+ .split(",").map(s => s.trim()).filter(Boolean);
1067
+ const CORS_WILDCARD = CORS_ALLOWLIST.length === 1 && CORS_ALLOWLIST[0] === "*";
1037
1068
  app.use((req, res, next) => {
1038
- res.setHeader("Access-Control-Allow-Origin", "*");
1069
+ const origin = req.get("origin") || "";
1070
+ if (CORS_WILDCARD) {
1071
+ res.setHeader("Access-Control-Allow-Origin", "*");
1072
+ } else if (origin && CORS_ALLOWLIST.includes(origin)) {
1073
+ res.setHeader("Access-Control-Allow-Origin", origin);
1074
+ res.setHeader("Vary", "Origin");
1075
+ }
1076
+ // If no match, do NOT set the header — browser will block the request.
1039
1077
  res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
1040
1078
  res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
1041
1079
  if (req.method === "OPTIONS") return res.sendStatus(204);
1042
1080
  next();
1043
1081
  });
1044
1082
 
1083
+ // ---------- Auth middleware ----------
1084
+ // No-op in local-only mode (MCP_AUTH_TOKEN unset). Enforces 401 when token is set.
1085
+ const MCP_TOKEN = process.env.MCP_AUTH_TOKEN || "";
1086
+ function requireAuth(req, res, next) {
1087
+ if (req.path === "/health") return next();
1088
+ if (!MCP_TOKEN) return next();
1089
+ const expected = `Bearer ${MCP_TOKEN}`;
1090
+ const got = req.get("authorization") || "";
1091
+ const ok = got.length === expected.length &&
1092
+ timingSafeEqual(Buffer.from(got), Buffer.from(expected));
1093
+ if (!ok) return res.status(401).json({ error: "unauthorized" });
1094
+ next();
1095
+ }
1096
+ app.use(requireAuth);
1097
+
1045
1098
  // ---------- Shared MCP handler ----------
1046
1099
  function createMcpHandler(groupName) {
1047
1100
  return async (req, res) => {
@@ -1886,10 +1939,30 @@ app.get("/groups", (_, res) => {
1886
1939
  // =============================================================================
1887
1940
 
1888
1941
  async function main() {
1889
- app.listen(PORT, () => {
1890
- console.log(`MCP Bridge v2.0.0 on port ${PORT}`);
1942
+ const isPublic = BIND_HOST !== "127.0.0.1" && BIND_HOST !== "localhost";
1943
+ if (isPublic && !process.env.MCP_AUTH_TOKEN) {
1944
+ console.error(
1945
+ "FATAL: refusing to bind a public interface without MCP_AUTH_TOKEN. " +
1946
+ "Generate one with: MCP_AUTH_TOKEN=$(openssl rand -base64 32)"
1947
+ );
1948
+ process.exit(1);
1949
+ }
1950
+ app.listen(PORT, BIND_HOST, () => {
1951
+ console.log(`MCP Bridge v2.0.0 on port ${PORT} (${BIND_HOST})`);
1891
1952
  const enabled = Object.entries(TOOL_GROUPS).filter(([, g]) => g.enabled).map(([n]) => n);
1892
1953
  console.log(`Active groups: ${enabled.join(", ")}`);
1954
+ // ADR-166 §6 — startup posture banner (helps operators see the security state at boot)
1955
+ console.log(
1956
+ `[security] bind=${BIND_HOST} auth=${process.env.MCP_AUTH_TOKEN ? "bearer" : "off (local-only)"} ` +
1957
+ `terminal=${MCP_ENABLE_TERMINAL ? "ENABLED (⚠ opt-in)" : "disabled"}`,
1958
+ );
1959
+ if (MCP_ENABLE_TERMINAL) {
1960
+ console.warn(
1961
+ "[security] WARNING: terminal_execute is enabled. This tool grants shell access " +
1962
+ "inside the bridge container to any client the auth layer accepts. Ensure " +
1963
+ "MCP_AUTH_TOKEN is set on any non-loopback bind. See ADR-166 §6 Phase 1d.",
1964
+ );
1965
+ }
1893
1966
  });
1894
1967
 
1895
1968
  const anyBackendNeeded = BACKEND_DEFS.some(isBackendNeeded);