create-evda 0.1.8 → 0.1.9

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/lib/mcp-auth.mjs CHANGED
@@ -21,6 +21,9 @@
21
21
  // EVDA_MCP_FALSIFY=skip-redirect-check the redirect URI is not compared, at /authorize or /token
22
22
  // EVDA_MCP_FALSIFY=log-content the call log carries the tool arguments (lib/mcp-http.mjs)
23
23
  // EVDA_MCP_FALSIFY=log-args every tool call logs its arguments, stdio and HTTP (lib/mcp.mjs)
24
+ //
25
+ // None of them may be set on a server the internet can reach: a --public-url outside loopback and the
26
+ // reserved test names refuses to start while any switch is set (HQ-2082, refuseFalsifyOnPublicServer).
24
27
 
25
28
  import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
26
29
  import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
@@ -28,7 +31,11 @@ import { homedir } from "node:os";
28
31
  import { join } from "node:path";
29
32
  import { EvdaError } from "./errors.mjs";
30
33
 
31
- export const FALSIFY = (mode, env = process.env) => String(env.EVDA_MCP_FALSIFY ?? "").split(",").map((m) => m.trim()).includes(mode);
34
+ // Every switch, so the startup guard can name the ones that are set rather than ask about each.
35
+ export const MCP_FALSIFY_MODES = ["no-token-check", "accept-expired", "accept-revoked", "skip-pkce", "skip-redirect-check", "log-content", "log-args"];
36
+
37
+ export const falsifyModes = (env = process.env) => String(env.EVDA_MCP_FALSIFY ?? "").split(",").map((m) => m.trim()).filter(Boolean);
38
+ export const FALSIFY = (mode, env = process.env) => falsifyModes(env).includes(mode);
32
39
 
33
40
  export const ACCESS_TTL_MS = 60 * 60 * 1000; // one hour; Claude refreshes on a 401
34
41
  export const REFRESH_TTL_MS = 30 * 24 * 60 * 60 * 1000; // thirty days, renewed on every rotation
@@ -44,6 +51,39 @@ export const SCOPE = "evda";
44
51
  export const HOSTED_REDIRECTS = ["https://claude.ai/api/mcp/auth_callback", "https://claude.com/api/mcp/auth_callback"];
45
52
  const LOOPBACK = new Set(["localhost", "127.0.0.1", "[::1]"]);
46
53
 
54
+ // Can a stranger on the internet reach this address? Loopback is this machine, and the reserved names
55
+ // (RFC 6761 .test .invalid .example .localhost, RFC 2606 example.com and its siblings) resolve nowhere,
56
+ // which is what the tests serve on. Anything else counts as reachable, including an address nobody can
57
+ // parse: a value this cannot read is the one most likely to be a real address typed wrong.
58
+ const RESERVED_TLDS = new Set(["test", "invalid", "example", "localhost"]);
59
+ const RESERVED_NAMES = new Set(["example.com", "example.net", "example.org"]);
60
+ export function isPublicUrl(url) {
61
+ let host;
62
+ try { host = new URL(String(url)).hostname.toLowerCase(); } catch { return true; }
63
+ if (LOOPBACK.has(host) || host === "::1" || /^127\./.test(host)) return false;
64
+ return !(RESERVED_TLDS.has(host.split(".").pop()) || RESERVED_NAMES.has(host));
65
+ }
66
+
67
+ /**
68
+ * HQ-2082: each test hook above turns off one of this server's refusals, and no-token-check turns off
69
+ * authentication altogether, so a switch set on the machine that serves the public address would open
70
+ * the record to anyone who finds it. One can arrive without anybody choosing it: exported from a shell
71
+ * profile, or carried in a run script copied from a test. So the server refuses to start instead.
72
+ *
73
+ * Config time, once, before anything listens. A per-request check would already have served the first
74
+ * request of a process that should never have started, and would leave the switches half working.
75
+ *
76
+ * Returns the switches that are set, which on loopback is where they go on working.
77
+ */
78
+ export function refuseFalsifyOnPublicServer(publicUrl, env = process.env) {
79
+ const set = falsifyModes(env);
80
+ if (!set.length || !isPublicUrl(publicUrl)) return set;
81
+ throw new EvdaError(
82
+ `REFUSED: EVDA_MCP_FALSIFY is set to ${set.join(", ")}, and --public-url ${publicUrl} is an address the internet can reach. Every one of those switches turns off a refusal this server makes, so serving that address with one set would hand the record to anyone who finds it. Unset EVDA_MCP_FALSIFY, or serve on loopback (--public-url http://127.0.0.1:<port>) to use them.`,
83
+ { code: "FALSIFY_ON_PUBLIC_URL", exit: 2 }
84
+ );
85
+ }
86
+
47
87
  export const sha256 = (s) => createHash("sha256").update(s).digest("hex");
48
88
  const b64url = (buf) => buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
49
89
  export const newSecret = (bytes = 32) => b64url(randomBytes(bytes));
package/lib/mcp-http.mjs CHANGED
@@ -7,6 +7,8 @@
7
7
  // It binds to 127.0.0.1 by default and speaks plain HTTP: TLS and the public name belong to the
8
8
  // tunnel in front of it (Tailscale Funnel on Hestia). --public-url is the address the world uses,
9
9
  // and every URL in the metadata is built from it rather than from a Host header anyone can send.
10
+ // When that address is one the internet can reach, no EVDA_MCP_FALSIFY switch may be set: the server
11
+ // refuses to start rather than serve the world with a refusal turned off (HQ-2082).
10
12
  //
11
13
  // The caller is always Claude. A remote client cannot be a model on the owner's own devices, and its
12
14
  // clientInfo is whatever it says, so read_by (HQ-1374) applies as it does to Claude locally.
@@ -21,7 +23,7 @@ import { createServer } from "node:http";
21
23
  import { appendFileSync } from "node:fs";
22
24
  import { randomBytes } from "node:crypto";
23
25
  import { openEvda } from "./manifest.mjs";
24
- import { handle, newSession, TOOLS } from "./mcp.mjs";
26
+ import { handle, newSession, TOOLS, clientInfoOf } from "./mcp.mjs";
25
27
  import { callerFrom } from "./readers.mjs";
26
28
  import * as auth from "./mcp-auth.mjs";
27
29
 
@@ -59,6 +61,7 @@ export function createMcpHttpServer({ dir, version, publicUrl, env = process.env
59
61
  const base = String(publicUrl).replace(/\/+$/, "");
60
62
  const baseUrl = new URL(base);
61
63
  if (baseUrl.protocol !== "https:" && !["127.0.0.1", "localhost"].includes(baseUrl.hostname)) throw new Error(`--public-url must be https (got ${base})`);
64
+ auth.refuseFalsifyOnPublicServer(base, env); // HQ-2082: no test switch on an address the world can reach
62
65
  const resource = `${base}/mcp`;
63
66
  const prmUrl = `${base}/.well-known/oauth-protected-resource/mcp`;
64
67
  const evda = openEvda(dir); // refuse to start on something that is not a record
@@ -75,14 +78,17 @@ export function createMcpHttpServer({ dir, version, publicUrl, env = process.env
75
78
  const url = new URL(req.url, base);
76
79
  const path = url.pathname;
77
80
  const ip = clientIp(req);
78
- const note = { who: null };
81
+ const note = { who: null, method: null, tool: null, reason: null };
79
82
  const send = (status, body, headers = {}) => {
80
83
  const isJson = body !== null && typeof body === "object";
81
84
  res.writeHead(status, { "cache-control": "no-store", "x-content-type-options": "nosniff", ...(isJson ? { "content-type": "application/json" } : {}), ...headers });
82
85
  res.end(body === null || body === undefined ? undefined : isJson ? JSON.stringify(body) : body);
83
86
  // Path only: a query string on /authorize carries a state and a challenge, and on a bad
84
87
  // request could carry anything.
85
- record({ http: req.method, path: knownPath(path), status, ms: clock() - started, ...(note.who ? { conn: note.who } : {}) });
88
+ // A refusal names why (HQ-2235): the error text this server wrote and the JSON-RPC method when
89
+ // the body parsed, never the body itself, so four different 400s can be told apart afterwards.
90
+ const why = status >= 400 && status < 500 ? { reason: note.reason ?? reasonOf(body), ...(note.method ? { method: note.method } : {}), ...(note.tool ? { tool: note.tool } : {}) } : {};
91
+ record({ http: req.method, path: knownPath(path), status, ms: clock() - started, ...(note.who ? { conn: note.who } : {}), ...why });
86
92
  };
87
93
  try {
88
94
  const now = clock();
@@ -143,6 +149,7 @@ export function createMcpHttpServer({ dir, version, publicUrl, env = process.env
143
149
  if (req.method === "DELETE") {
144
150
  const s = sessions.get(sid);
145
151
  if (s && s.family === grant.family) sessions.delete(sid);
152
+ if (!s) note.reason = "session not found";
146
153
  return send(s ? 204 : 404, null);
147
154
  }
148
155
  if (req.method !== "POST") return send(405, { error: "method_not_allowed" }, { allow: "POST, DELETE" });
@@ -151,6 +158,8 @@ export function createMcpHttpServer({ dir, version, publicUrl, env = process.env
151
158
  const raw = await readBody(req, MCP_BODY_LIMIT);
152
159
  try { msg = JSON.parse(raw); } catch { return send(400, { jsonrpc: "2.0", id: null, error: { code: -32700, message: "parse error" } }); }
153
160
  const batch = Array.isArray(msg) ? msg : [msg];
161
+ note.method = batch.map((x) => (typeof x?.method !== "string" ? "none" : METHODS.has(x.method) ? x.method : "unknown_method")).join(",");
162
+ if (batch.length === 1 && batch[0]?.method === "tools/call") note.tool = TOOL_NAMES.has(batch[0].params?.name) ? batch[0].params.name : "unknown_tool";
154
163
  const isInit = batch.some((x) => x?.method === "initialize");
155
164
  let session;
156
165
  let newSid = null;
@@ -158,7 +167,7 @@ export function createMcpHttpServer({ dir, version, publicUrl, env = process.env
158
167
  if (batch.length !== 1) return send(400, { jsonrpc: "2.0", id: null, error: { code: -32600, message: "initialize must be sent alone" } });
159
168
  evictSessions(now);
160
169
  newSid = randomBytes(24).toString("hex");
161
- session = { family: grant.family, last: now, ctx: { version, log: (s) => log(s), env: readerEnv, evda: () => openEvda(dir), session: newSession(), caller: callerFrom({ env: readerEnv }) } };
170
+ session = { family: grant.family, last: now, ctx: { version, log: (s) => log(s), clock, env: readerEnv, evda: () => openEvda(dir), session: newSession(), caller: callerFrom({ env: readerEnv }) } };
162
171
  sessions.set(newSid, session);
163
172
  } else {
164
173
  if (!sid) return send(400, { jsonrpc: "2.0", id: null, error: { code: -32600, message: "Mcp-Session-Id header required; initialize first" } });
@@ -177,6 +186,8 @@ export function createMcpHttpServer({ dir, version, publicUrl, env = process.env
177
186
  if (method && one?.id !== undefined && one?.id !== null) {
178
187
  const st = r?.error ? "error" : r?.result?.isError ? "refused" : "ok";
179
188
  const line = { call: method === "tools/call" ? (TOOL_NAMES.has(tool) ? tool : "unknown_tool") : METHODS.has(method) ? method : "unknown_method", status: st, ms: clock() - t0, session: shortId(sid ?? newSid), conn: note.who };
189
+ // Which app connected (HQ-2207): initialize's clientInfo name and version, nothing else of it.
190
+ if (method === "initialize") { const ci = clientInfoOf(one.params); line.client_name = ci.name; line.client_version = ci.version; }
180
191
  if (auth.FALSIFY("log-content", env)) line.args = one.params?.arguments;
181
192
  record(line);
182
193
  }
@@ -268,6 +279,15 @@ function page(title, body) {
268
279
  </head><body><h1>${esc(title)}</h1>${body}</body></html>`;
269
280
  }
270
281
 
282
+ // The error text of a refusal this server wrote: a JSON-RPC error message, an OAuth error_description
283
+ // or error code, or an HTML page's title. Server-authored strings only, capped.
284
+ function reasonOf(body) {
285
+ let r = null;
286
+ if (typeof body === "string") r = /<title>([^<]*)<\/title>/.exec(body)?.[1] ?? null;
287
+ else if (body && typeof body === "object") r = typeof body.error === "object" ? body.error?.message : body.error_description ?? body.error;
288
+ return typeof r === "string" ? r.slice(0, 200) : null;
289
+ }
290
+
271
291
  function withParams(uri, params) {
272
292
  const u = new URL(uri);
273
293
  for (const [k, v] of Object.entries(params)) if (v !== undefined && v !== null) u.searchParams.set(k, v);