@seldonframe/mcp 1.59.1 → 1.59.2

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": "@seldonframe/mcp",
3
- "version": "1.59.1",
3
+ "version": "1.59.2",
4
4
  "mcpName": "io.github.seldonframe/mcp",
5
5
  "description": "Open-source GoHighLevel alternative for agencies. 146+ MCP tools that let Claude Code spin up white-labeled client workspaces — CRM, booking, intake forms, landing pages, AI chatbot, and pre-wired agent archetypes (speed-to-lead, missed-call-text-back, review-requester) — in minutes. AGPL-3.0.",
6
6
  "license": "AGPL-3.0-or-later",
@@ -17,8 +17,9 @@
17
17
  },
18
18
  "scripts": {
19
19
  "start": "node src/index.js",
20
- "check:syntax": "node --check --input-type=module < src/index.js && node --check --input-type=module < src/client.js && node --check --input-type=module < src/tools.js && node --check --input-type=module < src/welcome.js",
21
- "prepublishOnly": "npm run check:syntax"
20
+ "check:syntax": "node --check --input-type=module < src/index.js && node --check --input-type=module < src/client.js && node --check --input-type=module < src/tools.js && node --check --input-type=module < src/welcome.js && node --check --input-type=module < src/security.js",
21
+ "test": "node --test tests/*.test.mjs",
22
+ "prepublishOnly": "npm run check:syntax && npm run test"
22
23
  },
23
24
  "dependencies": {
24
25
  "@modelcontextprotocol/sdk": "^1.0.4"
package/src/client.js CHANGED
@@ -2,6 +2,10 @@ import { mkdirSync, readFileSync, writeFileSync, existsSync } from "node:fs";
2
2
  import { homedir, hostname } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import { VERSION } from "./welcome.js";
5
+ // v1.59.2 — SSRF guard for fetchText, which fetches operator-supplied URLs
6
+ // directly from the MCP server's machine (fetch_source_for_soul's
7
+ // underlying implementation). See src/security.js for the guard + tests.
8
+ import { assertPublicHttpUrl } from "./security.js";
5
9
 
6
10
  const API_BASE =
7
11
  process.env.SELDONFRAME_API_BASE ?? "https://app.seldonframe.com/api/v1";
@@ -170,18 +174,48 @@ export async function api(method, path, opts = {}) {
170
174
  return data;
171
175
  }
172
176
 
177
+ // v1.59.2 — this fetches a URL the OPERATOR supplies (fetch_source_for_soul's
178
+ // "scrape my existing website" feature), directly from the machine the MCP
179
+ // server runs on. Without a guard, an operator (or a compromised/malicious
180
+ // upstream prompt) could point this at http://localhost:PORT, a LAN device,
181
+ // or a cloud metadata endpoint and have the MCP process fetch it on their
182
+ // behalf. assertPublicHttpUrl rejects non-public targets; redirects are
183
+ // followed MANUALLY (max 3 hops) so a public URL that 30x's to a private
184
+ // one can't slip past the initial check.
185
+ const MAX_REDIRECTS = 3;
186
+
173
187
  export async function fetchText(url, { maxBytes = 256 * 1024 } = {}) {
174
- const res = await fetch(url, {
175
- headers: { "User-Agent": `seldonframe-mcp/${VERSION} (+soul-compiler)` },
176
- redirect: "follow",
177
- });
188
+ let currentUrl = url;
189
+ let res;
190
+ for (let hop = 0; ; hop++) {
191
+ await assertPublicHttpUrl(currentUrl);
192
+ res = await fetch(currentUrl, {
193
+ headers: { "User-Agent": `seldonframe-mcp/${VERSION} (+soul-compiler)` },
194
+ redirect: "manual",
195
+ });
196
+ const isRedirect = res.status >= 300 && res.status < 400;
197
+ if (!isRedirect) break;
198
+ if (hop >= MAX_REDIRECTS) {
199
+ throw new Error(
200
+ `Fetch ${url} failed: exceeded ${MAX_REDIRECTS} redirects (stopped at ${currentUrl}).`,
201
+ );
202
+ }
203
+ const location = res.headers.get("location");
204
+ if (!location) {
205
+ throw new Error(
206
+ `Fetch ${currentUrl} returned redirect status ${res.status} with no Location header.`,
207
+ );
208
+ }
209
+ // Location may be relative — resolve against the URL that produced it.
210
+ currentUrl = new URL(location, currentUrl).toString();
211
+ }
178
212
  if (!res.ok) {
179
213
  throw new Error(`Fetch ${url} failed: ${res.status} ${res.statusText}`);
180
214
  }
181
215
  const raw = await res.text();
182
216
  const truncated = raw.length > maxBytes;
183
217
  const html = truncated ? raw.slice(0, maxBytes) : raw;
184
- return { html, truncated, status: res.status, final_url: res.url };
218
+ return { html, truncated, status: res.status, final_url: res.url || currentUrl };
185
219
  }
186
220
 
187
221
  export function htmlToText(html) {
@@ -0,0 +1,241 @@
1
+ // v1.59.2 — security hardening helpers shared by tools.js.
2
+ //
3
+ // Two independent concerns live here:
4
+ // 1. sniffImageKind — magic-byte detection so upload_workspace_image only
5
+ // ever forwards bytes that are actually image files (not arbitrary
6
+ // local files an agent was pointed at).
7
+ // 2. assertPublicHttpUrl — an SSRF guard for machine-side fetches of
8
+ // operator-supplied URLs (fetch_source_for_soul today). Rejects
9
+ // loopback / private / link-local targets so the MCP process can't be
10
+ // used to probe the operator's own LAN or cloud metadata endpoints.
11
+ //
12
+ // Both are pure-ish (assertPublicHttpUrl takes an injectable `lookup` for
13
+ // tests) and have zero dependency on the rest of tools.js so they can be
14
+ // unit-tested in isolation (see tests/security.test.mjs).
15
+
16
+ const MAX_SVG_BYTES = 1 * 1024 * 1024; // 1MB
17
+
18
+ // Loose but sufficient: optional UTF-8 BOM, optional XML prolog, optional
19
+ // DOCTYPE, then an <svg ...> or <svg> root tag. Case-insensitive.
20
+ const SVG_PATTERN =
21
+ /^?\s*(<\?xml[^>]*>\s*)?(<!DOCTYPE[^>]*>\s*)?<svg[\s>]/i;
22
+
23
+ /**
24
+ * Sniff the image kind of a buffer from its magic bytes. Returns one of
25
+ * "png" | "jpeg" | "gif" | "webp" | "svg", or null if the buffer doesn't
26
+ * look like a recognized image format.
27
+ *
28
+ * @param {Buffer} buffer
29
+ * @returns {"png"|"jpeg"|"gif"|"webp"|"svg"|null}
30
+ */
31
+ export function sniffImageKind(buffer) {
32
+ if (!Buffer.isBuffer(buffer) || buffer.length === 0) return null;
33
+
34
+ // PNG: 89 50 4E 47 (plus the usual 0D 0A 1A 0A trailer, but the first
35
+ // four bytes are sufficient to identify it unambiguously).
36
+ if (
37
+ buffer.length >= 4 &&
38
+ buffer[0] === 0x89 &&
39
+ buffer[1] === 0x50 &&
40
+ buffer[2] === 0x4e &&
41
+ buffer[3] === 0x47
42
+ ) {
43
+ return "png";
44
+ }
45
+
46
+ // JPEG: FF D8 FF
47
+ if (
48
+ buffer.length >= 3 &&
49
+ buffer[0] === 0xff &&
50
+ buffer[1] === 0xd8 &&
51
+ buffer[2] === 0xff
52
+ ) {
53
+ return "jpeg";
54
+ }
55
+
56
+ // GIF: ASCII "GIF8" (covers both GIF87a and GIF89a).
57
+ if (buffer.length >= 4 && buffer.toString("ascii", 0, 4) === "GIF8") {
58
+ return "gif";
59
+ }
60
+
61
+ // WebP: "RIFF" at offset 0, "WEBP" at offset 8.
62
+ if (
63
+ buffer.length >= 12 &&
64
+ buffer.toString("ascii", 0, 4) === "RIFF" &&
65
+ buffer.toString("ascii", 8, 12) === "WEBP"
66
+ ) {
67
+ return "webp";
68
+ }
69
+
70
+ // SVG: text-based, so magic bytes don't apply — sniff the text instead.
71
+ // Bound the size before decoding so a huge non-image file doesn't get
72
+ // fully UTF-8-decoded just to fail this check.
73
+ if (buffer.length <= MAX_SVG_BYTES) {
74
+ const text = buffer.toString("utf8");
75
+ if (SVG_PATTERN.test(text)) return "svg";
76
+ }
77
+
78
+ return null;
79
+ }
80
+
81
+ // ── SSRF guard ──────────────────────────────────────────────────────────
82
+
83
+ const BLOCKED_HOSTNAME_SUFFIXES = [".localhost", ".local", ".internal"];
84
+
85
+ function isBlockedHostname(hostname) {
86
+ const host = hostname.toLowerCase();
87
+ if (host === "localhost") return true;
88
+ return BLOCKED_HOSTNAME_SUFFIXES.some((suffix) => host.endsWith(suffix));
89
+ }
90
+
91
+ /**
92
+ * Parse an IPv4 dotted-quad string into four octet numbers, or null if it
93
+ * isn't a valid literal IPv4 address.
94
+ * @param {string} host
95
+ * @returns {[number,number,number,number]|null}
96
+ */
97
+ function parseIPv4(host) {
98
+ const parts = host.split(".");
99
+ if (parts.length !== 4) return null;
100
+ const octets = [];
101
+ for (const part of parts) {
102
+ if (!/^\d{1,3}$/.test(part)) return null;
103
+ const n = Number(part);
104
+ if (n < 0 || n > 255) return null;
105
+ octets.push(n);
106
+ }
107
+ return octets;
108
+ }
109
+
110
+ /**
111
+ * Is this IPv4 (as an octet quad) inside a non-public range?
112
+ * @param {[number,number,number,number]} o
113
+ */
114
+ function isNonPublicIPv4(o) {
115
+ const [a, b] = o;
116
+ if (a === 0) return true; // 0.0.0.0/8
117
+ if (a === 10) return true; // 10/8
118
+ if (a === 127) return true; // 127/8 (loopback)
119
+ if (a === 169 && b === 254) return true; // 169.254/16 (link-local)
120
+ if (a === 172 && b >= 16 && b <= 31) return true; // 172.16/12
121
+ if (a === 192 && b === 168) return true; // 192.168/16
122
+ if (a === 100 && b >= 64 && b <= 127) return true; // 100.64/10 (CGNAT)
123
+ return false;
124
+ }
125
+
126
+ /**
127
+ * Is this literal address (v4 or v6 string form) non-public? Handles
128
+ * IPv4-mapped IPv6 (::ffff:x.x.x.x) by unwrapping to the embedded IPv4.
129
+ * @param {string} address
130
+ */
131
+ function isNonPublicIpLiteral(address) {
132
+ const addr = address.toLowerCase();
133
+
134
+ // IPv4-mapped IPv6: ::ffff:x.x.x.x (or the rarer ::ffff:0:x.x.x.x form).
135
+ const mappedMatch = addr.match(/^::ffff:(?:0:)?(\d{1,3}(?:\.\d{1,3}){3})$/);
136
+ if (mappedMatch) {
137
+ const v4 = parseIPv4(mappedMatch[1]);
138
+ return v4 ? isNonPublicIPv4(v4) : true; // malformed embedded v4 = reject
139
+ }
140
+
141
+ const v4 = parseIPv4(addr);
142
+ if (v4) return isNonPublicIPv4(v4);
143
+
144
+ // IPv6 literal checks.
145
+ if (addr === "::1") return true; // loopback
146
+ if (addr === "::") return true; // unspecified
147
+ if (/^fc[0-9a-f]{2}:/.test(addr) || /^fd[0-9a-f]{2}:/.test(addr)) {
148
+ return true; // fc00::/7 (unique local) — first byte 0xfc or 0xfd
149
+ }
150
+ if (/^fe[89ab][0-9a-f]:/.test(addr)) return true; // fe80::/10 (link-local)
151
+
152
+ // Not a recognized literal at all (shouldn't happen given callers only
153
+ // pass here after confirming it parses as an IP) — fail closed.
154
+ return false;
155
+ }
156
+
157
+ /**
158
+ * Does this string parse as a literal IP address (v4 or v6)? Deliberately
159
+ * simple — good enough to distinguish "the caller already resolved this to
160
+ * an IP" from "this is a hostname that still needs DNS resolution."
161
+ * @param {string} host
162
+ */
163
+ function isIpLiteral(host) {
164
+ if (parseIPv4(host)) return true;
165
+ // Very small heuristic for IPv6 literal form: contains a colon and only
166
+ // hex digits / colons (optionally with an embedded dotted-quad tail for
167
+ // the IPv4-mapped form).
168
+ return /^[0-9a-f:]+(:\d{1,3}(?:\.\d{1,3}){3})?$/i.test(host) && host.includes(":");
169
+ }
170
+
171
+ /**
172
+ * Assert that a URL is safe for the MCP server (running on the operator's
173
+ * machine) to fetch directly: http(s) only, and resolves exclusively to
174
+ * public IP addresses. Throws with a clear message otherwise.
175
+ *
176
+ * Guards against SSRF-style abuse where an operator-supplied "scrape this
177
+ * URL" argument actually points at localhost, a LAN device, or a cloud
178
+ * metadata endpoint (169.254.169.254) reachable from wherever the MCP
179
+ * process happens to run.
180
+ *
181
+ * @param {string} urlString
182
+ * @param {{ lookup?: (hostname: string, opts: { all: true }) => Promise<{address:string,family:number}[]> }} [opts]
183
+ */
184
+ export async function assertPublicHttpUrl(urlString, opts = {}) {
185
+ let parsed;
186
+ try {
187
+ parsed = new URL(urlString);
188
+ } catch {
189
+ throw new Error(`assertPublicHttpUrl: "${urlString}" is not a valid URL.`);
190
+ }
191
+
192
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
193
+ throw new Error(
194
+ `assertPublicHttpUrl: protocol "${parsed.protocol}" is not allowed for "${urlString}" — only http: and https: are permitted.`,
195
+ );
196
+ }
197
+
198
+ // URL.hostname strips brackets from IPv6 literals (e.g. "[::1]" -> "::1").
199
+ const hostname = parsed.hostname;
200
+
201
+ if (isBlockedHostname(hostname)) {
202
+ throw new Error(
203
+ `assertPublicHttpUrl: hostname "${hostname}" is blocked (localhost / .localhost / .local / .internal).`,
204
+ );
205
+ }
206
+
207
+ if (isIpLiteral(hostname)) {
208
+ if (isNonPublicIpLiteral(hostname)) {
209
+ throw new Error(
210
+ `assertPublicHttpUrl: "${hostname}" is not a public IP address.`,
211
+ );
212
+ }
213
+ return;
214
+ }
215
+
216
+ const lookup =
217
+ opts.lookup ?? (await import("node:dns/promises")).lookup;
218
+ let records;
219
+ try {
220
+ records = await lookup(hostname, { all: true });
221
+ } catch (err) {
222
+ throw new Error(
223
+ `assertPublicHttpUrl: DNS lookup for "${hostname}" failed — ${err?.message ?? err}`,
224
+ );
225
+ }
226
+
227
+ if (!Array.isArray(records) || records.length === 0) {
228
+ throw new Error(
229
+ `assertPublicHttpUrl: DNS lookup for "${hostname}" returned no addresses.`,
230
+ );
231
+ }
232
+
233
+ for (const record of records) {
234
+ const address = record?.address ?? record;
235
+ if (typeof address !== "string" || isNonPublicIpLiteral(address)) {
236
+ throw new Error(
237
+ `assertPublicHttpUrl: "${hostname}" resolves to a non-public address (${address}) — refusing to fetch.`,
238
+ );
239
+ }
240
+ }
241
+ }
package/src/tools.js CHANGED
@@ -31,6 +31,10 @@ import { buildFinalizeSummary } from "./finalize-summary.js";
31
31
  // the agent token budget that v1.10.0's image_data_b64 path was bound by.
32
32
  import { readFileSync } from "node:fs";
33
33
  import path from "node:path";
34
+ // v1.59.2 — security hardening: image-content sniffing (upload_workspace_image)
35
+ // and the SSRF guard for machine-side fetches of operator-supplied URLs
36
+ // (fetch_source_for_soul). See src/security.js for details + tests.
37
+ import { sniffImageKind, assertPublicHttpUrl } from "./security.js";
34
38
 
35
39
  const str = (description, extra = {}) => ({ type: "string", description, ...extra });
36
40
  const obj = (properties, required = []) => ({
@@ -1399,10 +1403,13 @@ export const TOOLS = [
1399
1403
  {
1400
1404
  name: "fetch_source_for_soul",
1401
1405
  description:
1402
- "Fetch a URL and return normalized text (headings + body, up to 256KB). Use this to gather raw content from the operator's existing website; then extract a structured business profile and save it with submit_soul. Zero LLM cost to SeldonFrame — extraction runs in this session.",
1406
+ "Fetch a URL and return normalized text (headings + body, up to 256KB). Use this to gather raw content from the operator's existing website; then extract a structured business profile and save it with submit_soul. Zero LLM cost to SeldonFrame — extraction runs in this session. " +
1407
+ "v1.59.2 — only public http(s) URLs are fetched: the MCP server rejects localhost / private-network / link-local targets (and any redirect that lands on one) before making the request. Use this for the operator's real, public-facing website only.",
1403
1408
  inputSchema: obj(
1404
1409
  {
1405
- url: str("Absolute URL to fetch."),
1410
+ url: str(
1411
+ "Absolute URL to fetch. Must be a public http(s) URL — localhost, LAN, and other private-network addresses are rejected.",
1412
+ ),
1406
1413
  },
1407
1414
  ["url"],
1408
1415
  ),
@@ -4088,6 +4095,7 @@ export const TOOLS = [
4088
4095
  "(b) `local_file_path` (v1.10.1+) — absolute path on the operator's machine. The MCP server (running locally) reads the file and forwards bytes to the backend. Best path for files on the operator's desktop. file_name + content_type derived from the path. " +
4089
4096
  "(c) `image_data_b64` (legacy v1.10.0) — image bytes base64-encoded. Use only when you've generated bytes yourself (e.g. dynamic image gen) and there's no URL or path. Be aware: the encoded string consumes your tool-call token budget; for files >~12 KB raw, prefer (a) or (b). " +
4090
4097
  "Max 5 MB across all paths. Allowed types: image/png, image/jpeg, image/webp, image/svg+xml, image/gif. " +
4098
+ "v1.59.2 — for local_file_path and image_data_b64, the MCP server sniffs the actual bytes (magic numbers / SVG root tag) before uploading anything; a file that isn't a recognized image format is rejected locally and never reaches the network. This tool cannot be used to exfiltrate arbitrary local files under an image_url. " +
4091
4099
  "Returns the public Blob URL on success; that URL is now live on the workspace's public surface within seconds. " +
4092
4100
  "Antifragile design: server only validates file shape + applies URL to the right column. Your LLM picks which slot ('they said logo, that maps to slot=logo'). As you get better at intent-mapping, the harness doesn't change.",
4093
4101
  inputSchema: obj(
@@ -4162,6 +4170,26 @@ export const TOOLS = [
4162
4170
  `upload_workspace_image: failed to read local_file_path "${filePath}" — ${err?.message ?? err}`,
4163
4171
  );
4164
4172
  }
4173
+ // v1.59.2 — this is the local-file-read path (the MCP server reads
4174
+ // whatever absolute path it's given). Enforce the size cap and
4175
+ // sniff the actual bytes BEFORE they're base64'd and sent anywhere,
4176
+ // so this tool can only ever upload real images — not an
4177
+ // agent-mediated read of an arbitrary local file.
4178
+ if (buf.length > 5 * 1024 * 1024) {
4179
+ return {
4180
+ ok: false,
4181
+ error: "file_too_large",
4182
+ message: `upload_workspace_image: "${filePath}" is ${buf.length} bytes, over the 5MB max.`,
4183
+ };
4184
+ }
4185
+ if (sniffImageKind(buf) === null) {
4186
+ return {
4187
+ ok: false,
4188
+ error: "not_an_image",
4189
+ message:
4190
+ "Not a recognized image file (png, jpeg, gif, webp, svg). This tool only uploads images.",
4191
+ };
4192
+ }
4165
4193
  // Derive file_name + content_type from the path extension, mirroring
4166
4194
  // the server-side image_url logic so the two paths feel identical
4167
4195
  // to the operator.
@@ -4186,12 +4214,40 @@ export const TOOLS = [
4186
4214
  }
4187
4215
  } else {
4188
4216
  // image_data_b64 — caller supplied bytes directly.
4189
- body.image_data_b64 = args.image_data_b64;
4190
4217
  if (!args.file_name || !args.content_type) {
4191
4218
  throw new Error(
4192
4219
  "upload_workspace_image: file_name and content_type are required with image_data_b64. (image_url and local_file_path auto-derive them.)",
4193
4220
  );
4194
4221
  }
4222
+ // v1.59.2 — same bytes-in, sniff-before-send discipline as the
4223
+ // local_file_path branch above (this tool only uploads images,
4224
+ // regardless of which of the three sources supplied the bytes).
4225
+ let decoded;
4226
+ try {
4227
+ decoded = Buffer.from(args.image_data_b64, "base64");
4228
+ } catch (err) {
4229
+ return {
4230
+ ok: false,
4231
+ error: "invalid_base64",
4232
+ message: `upload_workspace_image: image_data_b64 could not be decoded — ${err?.message ?? err}`,
4233
+ };
4234
+ }
4235
+ if (decoded.length > 5 * 1024 * 1024) {
4236
+ return {
4237
+ ok: false,
4238
+ error: "file_too_large",
4239
+ message: `upload_workspace_image: decoded image_data_b64 is ${decoded.length} bytes, over the 5MB max.`,
4240
+ };
4241
+ }
4242
+ if (sniffImageKind(decoded) === null) {
4243
+ return {
4244
+ ok: false,
4245
+ error: "not_an_image",
4246
+ message:
4247
+ "Not a recognized image file (png, jpeg, gif, webp, svg). This tool only uploads images.",
4248
+ };
4249
+ }
4250
+ body.image_data_b64 = args.image_data_b64;
4195
4251
  body.file_name = args.file_name;
4196
4252
  body.content_type = args.content_type;
4197
4253
  }
@@ -4588,7 +4644,8 @@ export const TOOLS = [
4588
4644
  "The OPERATOR pays the LLM provider directly (Anthropic / OpenAI / etc.); SF charges separately for agent platform usage. " +
4589
4645
  "Stored encrypted at rest using the deployment's ENCRYPTION_KEY. " +
4590
4646
  "Operators get keys from console.anthropic.com (recommended for v1.26.x — best tool-use support) or platform.openai.com. " +
4591
- "v1.28+ AUTO-DETECT: pass api_key='env' (or omit api_key entirely) and the MCP server will read process.env.ANTHROPIC_API_KEY / OPENAI_API_KEY from its own environment. Most Claude Code users already have this set (it's how Claude Code works), so this lets a solo SF client onboard with zero key-paste step. Returns { ok: false, error: 'no_env_key' } if the env var isn't set; in that case the user must paste the key explicitly. " +
4647
+ "v1.28+ AUTO-DETECT: pass api_key='env' (or omit api_key entirely) to read process.env.ANTHROPIC_API_KEY / OPENAI_API_KEY from the MCP server's own environment. " +
4648
+ "v1.59.2 — CONSENT REQUIRED before storing an env-detected key: ask the user first ('I found an Anthropic key in your environment — OK to store it in your SeldonFrame workspace?'), then re-call with confirm_store_env_key: true. Without that flag, this tool reports the env var it found and stops — it does NOT transmit the value. A key passed explicitly via api_key is consent by definition and is never gated. Returns { ok: false, error: 'no_env_key' } if the env var isn't set; in that case the user must paste the key explicitly. " +
4592
4649
  "Skip if the workspace already has a key — agents fail-graceful with 'I'm not set up yet' if no key configured, so a 'not configured' chatbot response means CALL THIS TOOL.",
4593
4650
  inputSchema: obj(
4594
4651
  {
@@ -4600,8 +4657,13 @@ export const TOOLS = [
4600
4657
  "LLM provider. v1.26 ships full Anthropic support (tool use, streaming-ready). OpenAI support for chat is partial — recommend Anthropic for production agents.",
4601
4658
  },
4602
4659
  api_key: str(
4603
- "API key. Anthropic keys start with 'sk-ant-...'. Stored encrypted; never echoed back. v1.28+ AUTO-DETECT: pass 'env' (literal string) or omit entirely to read process.env.{ANTHROPIC,OPENAI}_API_KEY from the MCP server's local environment.",
4660
+ "API key. Anthropic keys start with 'sk-ant-...'. Stored encrypted; never echoed back. v1.28+ AUTO-DETECT: pass 'env' (literal string) or omit entirely to read process.env.{ANTHROPIC,OPENAI}_API_KEY from the MCP server's local environment (subject to the confirm_store_env_key consent gate below). Passing the key explicitly here IS consent — it's never gated.",
4604
4661
  ),
4662
+ confirm_store_env_key: {
4663
+ type: "boolean",
4664
+ description:
4665
+ "Set true ONLY after the user has explicitly confirmed storing the API key found in this machine's environment variables in their SeldonFrame workspace.",
4666
+ },
4605
4667
  },
4606
4668
  ["workspace_id", "provider"],
4607
4669
  ),
@@ -4628,6 +4690,20 @@ export const TOOLS = [
4628
4690
  envName,
4629
4691
  };
4630
4692
  }
4693
+ // v1.59.2 — CONSENT GATE. Finding a key in the environment is not
4694
+ // the same as the operator agreeing to store it server-side.
4695
+ // Without explicit confirm_store_env_key: true, stop here and
4696
+ // tell the agent to go get consent — never transmit the value.
4697
+ if (args.confirm_store_env_key !== true) {
4698
+ return {
4699
+ ok: true,
4700
+ needs_consent: true,
4701
+ env_var_found: envName,
4702
+ message:
4703
+ `Found ${envName} in the environment. SeldonFrame can store this key in the workspace (server-side, used only to power the hosted chatbot/agent). ` +
4704
+ `Ask the user for permission first, then re-call this tool with confirm_store_env_key: true.`,
4705
+ };
4706
+ }
4631
4707
  }
4632
4708
 
4633
4709
  const result = await api("POST", "/agents", {
@@ -5009,7 +5085,8 @@ export const TOOLS = [
5009
5085
  "(2) creates a website-chatbot agent with the FAQ + pricing facts + greeting you provide; " +
5010
5086
  "(3) publishes to status='test' so the operator can sandbox-test before going live (the eval gate runs only on 'live'); " +
5011
5087
  "(4) returns the embed snippet, dashboard URL, and clear next-steps. " +
5012
- "USE THIS as the default for natural-language 'create a chatbot' requests. Fall back to the primitive tools (configure_llm_provider + create_agent + publish_agent) only when you need a custom flow (e.g. agency managing multiple operators with separate Anthropic billing — pass anthropic_api_key explicitly per workspace).",
5088
+ "USE THIS as the default for natural-language 'create a chatbot' requests. Fall back to the primitive tools (configure_llm_provider + create_agent + publish_agent) only when you need a custom flow (e.g. agency managing multiple operators with separate Anthropic billing — pass anthropic_api_key explicitly per workspace). " +
5089
+ "v1.59.2 — CONSENT REQUIRED before storing an env-detected key: if no anthropic_api_key is passed and process.env.ANTHROPIC_API_KEY is found, this tool does NOT store it on the first call. It reports what it found and stops (nothing else in the bundle runs yet). Ask the user for permission, then re-call with confirm_store_env_key: true to proceed with the full bundle. If neither an explicit key nor an env key is present, the bundle proceeds anyway on the SeldonFrame platform key — no consent needed because nothing of the operator's is transmitted.",
5013
5090
  inputSchema: obj(
5014
5091
  {
5015
5092
  workspace_id: str("Workspace id (bearer workspace)."),
@@ -5045,8 +5122,13 @@ export const TOOLS = [
5045
5122
  "First message shown when chat opens (~120 chars). E.g. 'Hi! Asking about HVAC service in Phoenix? I can book you in or answer common questions.' Default if omitted: 'Hi! How can I help you today?'",
5046
5123
  ),
5047
5124
  anthropic_api_key: str(
5048
- "Optional explicit Anthropic API key (sk-ant-...). If omitted, reads from process.env.ANTHROPIC_API_KEY in the MCP server's environment. Pass explicitly for white-label scenarios (different operator = different Anthropic billing). Skipped entirely if the workspace already has a key configured.",
5125
+ "Optional explicit Anthropic API key (sk-ant-...). If omitted, reads from process.env.ANTHROPIC_API_KEY in the MCP server's environment (subject to the confirm_store_env_key consent gate below). Pass explicitly for white-label scenarios (different operator = different Anthropic billing) — passing it here IS consent, never gated. Skipped entirely if the workspace already has a key configured.",
5049
5126
  ),
5127
+ confirm_store_env_key: {
5128
+ type: "boolean",
5129
+ description:
5130
+ "Set true ONLY after the user has explicitly confirmed storing the API key found in this machine's environment variables in their SeldonFrame workspace.",
5131
+ },
5050
5132
  },
5051
5133
  ["workspace_id", "name"],
5052
5134
  ),
@@ -5072,6 +5154,24 @@ export const TOOLS = [
5072
5154
  // that v1.40.9 Sunset Plumbing test exposed.
5073
5155
  const explicitKey = args.anthropic_api_key;
5074
5156
  const envKey = process.env.ANTHROPIC_API_KEY;
5157
+
5158
+ // v1.59.2 — CONSENT GATE. An env-inherited key is about to be
5159
+ // transmitted to the SF API and stored on the workspace; the
5160
+ // operator hasn't agreed to that yet just by virtue of having the
5161
+ // var set in their shell. Stop the ENTIRE bundle here (before
5162
+ // create_agent / publish_agent run) and ask first. An explicit
5163
+ // anthropic_api_key arg is consent by definition and skips this.
5164
+ if (!explicitKey && envKey && args.confirm_store_env_key !== true) {
5165
+ return {
5166
+ ok: true,
5167
+ needs_consent: true,
5168
+ env_var_found: "ANTHROPIC_API_KEY",
5169
+ message:
5170
+ "Found ANTHROPIC_API_KEY in the environment. SeldonFrame can store this key in the workspace (server-side, used only to power the hosted chatbot/agent). " +
5171
+ "Ask the user for permission first, then re-call this tool with confirm_store_env_key: true.",
5172
+ };
5173
+ }
5174
+
5075
5175
  const keyToUse = explicitKey || envKey;
5076
5176
  let llmMode = "platform";
5077
5177
  if (keyToUse) {
package/src/welcome.js CHANGED
@@ -8,7 +8,7 @@
8
8
  // stripped. `create_full_workspace` is the only workspace-creation
9
9
  // path mentioned anywhere in this briefing.
10
10
 
11
- export const VERSION = "1.59.1";
11
+ export const VERSION = "1.59.2";
12
12
 
13
13
  export const WELCOME_MARKDOWN = `# SeldonFrame — create a real Business OS in one conversation
14
14
 
@@ -108,8 +108,17 @@ of v1.28.0 this is **ONE tool call**:
108
108
  // platform key automatically. Operator can BYOK later via
109
109
  // /settings/integrations/llm. Pass explicitly only when you need
110
110
  // per-workspace billing isolation (white-label / agency).
111
+ //
112
+ // v1.59.2 — if you omit anthropic_api_key AND the MCP server's
113
+ // environment has ANTHROPIC_API_KEY set, this call returns
114
+ // needs_consent: true instead of building anything. ASK THE USER
115
+ // before storing a key found in the environment ("I found an
116
+ // Anthropic key in your environment — OK to store it in your
117
+ // SeldonFrame workspace?"), then re-call with
118
+ // confirm_store_env_key: true.
111
119
  })
112
120
  // → returns { agent, embed_url, turn_api_url, turn_api_method: "POST", dashboard_url, llm_mode, next_steps }
121
+ // or, if an env key needs consent first: { ok: true, needs_consent: true, env_var_found, message }.
113
122
  // Internally: creates agent, publishes to test. Skips set_llm_key when
114
123
  // no key supplied (platform fallback in lib/ai/client.ts handles inference).
115
124
 
@@ -125,18 +134,25 @@ billing (e.g. Acme AI agency managing Cypress Pine HVAC + Sunset
125
134
  Dental), pass anthropic_api_key explicitly per workspace instead of
126
135
  relying on platform fallback.
127
136
 
128
- ### v1.40.10 — LLM key handling
137
+ ### v1.40.10 — LLM key handling (v1.59.2 — consent gate on path 2)
129
138
 
130
139
  build_website_chatbot has THREE possible LLM-key paths, in priority
131
140
  order:
132
141
 
133
142
  1. **Explicit** — \`anthropic_api_key: 'sk-ant-...'\` arg → workspace
134
- stored as BYOK, operator pays Anthropic directly.
143
+ stored as BYOK, operator pays Anthropic directly. Passing the key
144
+ explicitly IS consent — this path is never gated.
135
145
  2. **Env-inherited** — process.env.ANTHROPIC_API_KEY in the MCP
136
- server's environment → workspace stored as BYOK with that key.
146
+ server's environment → the DEFAULT is to ASK FIRST, not store
147
+ silently. The first call (no confirm_store_env_key) returns
148
+ \`{ needs_consent: true, env_var_found, message }\` and does nothing
149
+ else. Tell the operator what was found and ask permission; only
150
+ after they agree, re-call with \`confirm_store_env_key: true\` to
151
+ store the key as BYOK and continue the bundle.
137
152
  3. **Platform fallback** — neither explicit nor env → no BYOK
138
- stored. The workspace uses SeldonFrame's platform Anthropic key
139
- automatically (via lib/ai/client.ts -> getAIClient). Operator
153
+ stored, no consent needed (nothing of the operator's is
154
+ transmitted). The workspace uses SeldonFrame's platform Anthropic
155
+ key automatically (via lib/ai/client.ts -> getAIClient). Operator
140
156
  sees no setup friction; they can BYOK later in
141
157
  /settings/integrations/llm.
142
158
 
@@ -145,7 +161,8 @@ the operator: "Your chatbot is using SeldonFrame's shared Anthropic
145
161
  key. You can switch to your own anytime in Settings → Integrations
146
162
  → LLM, no code changes needed." This removes the "where do I get an
147
163
  Anthropic key" question from the critical path of a first-time
148
- chatbot setup.
164
+ chatbot setup — and it means path 2's consent step only ever comes up
165
+ when an env key actually exists, not as a blocking default.
149
166
 
150
167
  For custom flows (different archetype, custom capability allowlist,
151
168
  multi-step blueprint construction), drop down to the primitives: