@lyric_dev/data-loom 0.4.0 → 0.4.1

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/README.md CHANGED
@@ -81,6 +81,17 @@ The running daemon also **hosts an MCP server** over HTTP, so your own Claude se
81
81
 
82
82
  > **Upgrading from an earlier version?** The MCP server used to be a per-project stdio registration (`claude mcp add data-loom -- npx … mcp "<path>"`). That mode is gone. Remove any old per-project `data-loom` registrations and add the single user-scope HTTP one above.
83
83
 
84
+ ## Security
85
+
86
+ DataLoom runs entirely on `127.0.0.1` and is built for a single local user.
87
+
88
+ - **Loopback only** — the daemon (dashboard, WebSocket, and MCP endpoint) binds to the loopback interface and is never exposed to the network.
89
+ - **Host + Origin validated** — every HTTP request and WebSocket upgrade is checked: requests with a non-loopback `Host` (DNS-rebinding) or a non-loopback `Origin` (cross-site requests from a web page) are rejected. Native MCP clients (which send no `Origin`) are allowed. This is what keeps a random website you visit from driving the daemon.
90
+ - **No secrets** — the MCP tools carry only proposal text and change names; the topology view redacts server commands/args and shows scheme+host only. No API keys or config are read or returned, and the daemon holds no credential.
91
+ - **Bounded** — request bodies are capped (4 MB) and MCP sessions are capped and idle-evicted, so a local client can't exhaust memory.
92
+
93
+ The MCP tools can read and write any OpenSpec workspace path you point them at (this is intentional, so the tool can stay LLM-provider-independent). That means: treat the daemon as you would any local dev server — fine for your own machine, not something to run on a shared/multi-user host.
94
+
84
95
  ## Design principles
85
96
 
86
97
  - **Derived, not stored** — phase/order is a pure function of the OpenSpec files, recomputed on every change. Nothing about ordering is persisted.
package/dist/mcpServer.js CHANGED
@@ -15,6 +15,14 @@ import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprot
15
15
  import { OpenSpecClient } from "./openspecClient.js";
16
16
  import { deriveModel } from "./derive.js";
17
17
  import { discoverProjects, isViewableProject } from "./projects.js";
18
+ /**
19
+ * A validation error whose message is safe to return to the client because it
20
+ * only references caller-supplied input (a change name, a path the caller named).
21
+ * Anything that is NOT a ToolError is treated as an unexpected internal failure
22
+ * and reported generically, so host filesystem detail never leaks.
23
+ */
24
+ class ToolError extends Error {
25
+ }
18
26
  // Advertised to the client on connect. The "confirm before writing" gate lives
19
27
  // here, in the agent's behavior — the server cannot verify a human approved.
20
28
  const INSTRUCTIONS = `This server exposes a project's open OpenSpec proposals and lets you record the dependency order between them. It holds no model and cannot infer anything on its own — the judgment is yours and the decision is the user's.
@@ -67,18 +75,18 @@ const PROJECT_ARG = {
67
75
  * the dashboard selection is the single fallback source of project truth.
68
76
  */
69
77
  export function createMcpServer(deps) {
70
- const server = new Server({ name: "data-loom", version: "0.4.0" }, { capabilities: { tools: {} }, instructions: INSTRUCTIONS });
78
+ const server = new Server({ name: "data-loom", version: "0.4.1" }, { capabilities: { tools: {} }, instructions: INSTRUCTIONS });
71
79
  // Resolve the target project for a call: explicit arg, then dashboard
72
80
  // selection, then an instructive error. Validated as a real workspace before
73
81
  // any read or write.
74
82
  const resolveProject = (explicit) => {
75
83
  const raw = typeof explicit === "string" && explicit.trim() ? explicit : deps.getCurrentProject();
76
84
  if (!raw) {
77
- throw new Error("No `project` given and no project is selected in the DataLoom dashboard. Call list_projects to see the available OpenSpec workspaces, then pass one as the `project` argument.");
85
+ throw new ToolError("No `project` given and no project is selected in the DataLoom dashboard. Call list_projects to see the available OpenSpec workspaces, then pass one as the `project` argument.");
78
86
  }
79
87
  const abs = resolve(raw);
80
88
  if (!isViewableProject(abs)) {
81
- throw new Error(`${abs} is not an OpenSpec workspace (no openspec/ directory). Call list_projects to see the available workspaces.`);
89
+ throw new ToolError(`${abs} is not an OpenSpec workspace (no openspec/ directory). Call list_projects to see the available workspaces.`);
82
90
  }
83
91
  return abs;
84
92
  };
@@ -161,7 +169,12 @@ export function createMcpServer(deps) {
161
169
  return err(`unknown tool: ${name}`);
162
170
  }
163
171
  catch (e) {
164
- return err(e instanceof Error ? e.message : String(e));
172
+ // Validation errors (caller-supplied input) are safe to surface; anything
173
+ // else is an unexpected internal failure → generic message, detail to log.
174
+ if (e instanceof ToolError)
175
+ return err(e.message);
176
+ console.error("[data-loom mcp] tool error:", e);
177
+ return err("internal error");
165
178
  }
166
179
  });
167
180
  return server;
@@ -202,14 +215,14 @@ async function listOpenProposals(client, changesDir, project) {
202
215
  }
203
216
  async function setDependency(client, changesDir, project, from, to) {
204
217
  if (!from || !to)
205
- throw new Error("both 'from' and 'to' are required");
218
+ throw new ToolError("both 'from' and 'to' are required");
206
219
  if (from === to)
207
- throw new Error("a change cannot depend on itself");
220
+ throw new ToolError("a change cannot depend on itself");
208
221
  const names = new Set((await openChanges(client)).map((c) => c.name));
209
222
  if (!names.has(from))
210
- throw new Error(`unknown open change: ${from}`);
223
+ throw new ToolError(`unknown open change: ${from}`);
211
224
  if (!names.has(to))
212
- throw new Error(`unknown open change: ${to}`);
225
+ throw new ToolError(`unknown open change: ${to}`);
213
226
  const path = join(changesDir, from, "proposal.md");
214
227
  const text = await readFile(path, "utf8");
215
228
  const updated = addDependsOn(text, to);
@@ -220,10 +233,10 @@ async function setDependency(client, changesDir, project, from, to) {
220
233
  }
221
234
  async function markIndependent(client, changesDir, project, change) {
222
235
  if (!change)
223
- throw new Error("'change' is required");
236
+ throw new ToolError("'change' is required");
224
237
  const names = new Set((await openChanges(client)).map((c) => c.name));
225
238
  if (!names.has(change))
226
- throw new Error(`unknown open change: ${change}`);
239
+ throw new ToolError(`unknown open change: ${change}`);
227
240
  const path = join(changesDir, change, "proposal.md");
228
241
  const text = await readFile(path, "utf8");
229
242
  const updated = ensureDependsOnSection(text);
package/dist/server.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // Serve the SPA on loopback and push roadmap + MCP + project state over a websocket.
2
2
  import { createServer } from "node:http";
3
3
  import { randomUUID } from "node:crypto";
4
- import { extname } from "node:path";
4
+ import { extname, resolve, sep } from "node:path";
5
5
  import { WebSocketServer, WebSocket } from "ws";
6
6
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
7
7
  import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
@@ -24,53 +24,110 @@ export async function startServer(opts) {
24
24
  ws.send(data);
25
25
  }
26
26
  };
27
+ // Loopback origin/host allowlist — the DNS-rebinding + CSRF guard. Built from
28
+ // the actually-bound port after listen; seeded from the requested port so the
29
+ // guard is active from the first request.
30
+ const loopbackHosts = ["127.0.0.1", "localhost", "[::1]"];
31
+ let allowedAuthorities = new Set();
32
+ const setAllowed = (p) => {
33
+ allowedAuthorities = new Set(loopbackHosts.map((h) => `${h}:${p}`));
34
+ };
35
+ setAllowed(port);
36
+ // Reject what a hostile web page or a rebound DNS name could mount: a Host
37
+ // that is not our loopback authority (DNS rebinding), or any *present* Origin
38
+ // that is not a loopback origin (CSRF). An absent Origin = a native, non-
39
+ // browser client (e.g. Claude Code), which is allowed.
40
+ const isAllowed = (req) => {
41
+ const host = req.headers.host;
42
+ if (!host || !allowedAuthorities.has(host))
43
+ return false;
44
+ const origin = req.headers.origin;
45
+ if (origin) {
46
+ try {
47
+ if (!allowedAuthorities.has(new URL(origin).host))
48
+ return false;
49
+ }
50
+ catch {
51
+ return false;
52
+ }
53
+ }
54
+ return true;
55
+ };
27
56
  // One MCP server + transport per client session (stateful Streamable-HTTP),
28
- // keyed by the session id. Each session's tools resolve their target project
29
- // per call from getCurrentProject (see mcpServer.ts), so one daemon serves
30
- // every project.
31
- const mcpTransports = new Map();
57
+ // keyed by session id. Sessions are capped and idle-evicted so churn cannot
58
+ // grow memory without bound. Tools resolve their target project per call from
59
+ // getCurrentProject (see mcpServer.ts), so one daemon serves every project.
60
+ const MAX_SESSIONS = 32;
61
+ const SESSION_IDLE_TTL_MS = 30 * 60 * 1000;
62
+ const mcpSessions = new Map();
63
+ const idleSweep = setInterval(() => {
64
+ const now = Date.now();
65
+ for (const [sid, s] of mcpSessions) {
66
+ if (now - s.lastSeen > SESSION_IDLE_TTL_MS) {
67
+ mcpSessions.delete(sid);
68
+ void s.transport.close();
69
+ }
70
+ }
71
+ }, 60 * 1000);
72
+ idleSweep.unref();
32
73
  const handleMcp = async (req, res) => {
33
74
  const sessionId = req.headers["mcp-session-id"];
34
75
  try {
35
76
  if (req.method === "POST") {
36
77
  const body = await readJsonBody(req);
37
- let transport = sessionId ? mcpTransports.get(sessionId) : undefined;
38
- if (!transport) {
39
- if (!isInitializeRequest(body)) {
40
- return sendError(res, 400, "missing or invalid mcp-session-id");
41
- }
42
- transport = new StreamableHTTPServerTransport({
43
- sessionIdGenerator: () => randomUUID(),
44
- onsessioninitialized: (sid) => {
45
- mcpTransports.set(sid, transport);
46
- },
47
- });
48
- transport.onclose = () => {
49
- if (transport.sessionId)
50
- mcpTransports.delete(transport.sessionId);
51
- };
52
- await createMcpServer({ getCurrentProject }).connect(transport);
78
+ const existing = sessionId ? mcpSessions.get(sessionId) : undefined;
79
+ if (existing) {
80
+ existing.lastSeen = Date.now();
81
+ await existing.transport.handleRequest(req, res, body);
82
+ return;
83
+ }
84
+ if (!isInitializeRequest(body)) {
85
+ return sendError(res, 400, "missing or invalid mcp-session-id");
53
86
  }
87
+ if (mcpSessions.size >= MAX_SESSIONS) {
88
+ return sendError(res, 503, "too many sessions");
89
+ }
90
+ const transport = new StreamableHTTPServerTransport({
91
+ sessionIdGenerator: () => randomUUID(),
92
+ onsessioninitialized: (sid) => {
93
+ mcpSessions.set(sid, { transport, lastSeen: Date.now() });
94
+ },
95
+ });
96
+ transport.onclose = () => {
97
+ if (transport.sessionId)
98
+ mcpSessions.delete(transport.sessionId);
99
+ };
100
+ await createMcpServer({ getCurrentProject }).connect(transport);
54
101
  await transport.handleRequest(req, res, body);
55
102
  return;
56
103
  }
57
104
  if (req.method === "GET" || req.method === "DELETE") {
58
- const transport = sessionId ? mcpTransports.get(sessionId) : undefined;
59
- if (!transport)
105
+ const s = sessionId ? mcpSessions.get(sessionId) : undefined;
106
+ if (!s)
60
107
  return sendError(res, 400, "missing or invalid mcp-session-id");
61
- await transport.handleRequest(req, res);
108
+ s.lastSeen = Date.now();
109
+ await s.transport.handleRequest(req, res);
62
110
  return;
63
111
  }
64
112
  return sendError(res, 405, "method not allowed");
65
113
  }
66
114
  catch (e) {
67
- if (!res.headersSent) {
68
- sendError(res, 500, e instanceof Error ? e.message : "mcp error");
115
+ if (e instanceof PayloadTooLargeError) {
116
+ if (!res.headersSent)
117
+ sendError(res, 413, "request body too large");
118
+ return;
69
119
  }
120
+ // Unexpected failures stay in the server log; the client gets nothing
121
+ // about the host (no paths, no stack).
122
+ console.error("[data-loom] mcp request error:", e);
123
+ if (!res.headersSent)
124
+ sendError(res, 500, "internal error");
70
125
  }
71
126
  };
72
127
  const http = createServer(async (req, res) => {
73
128
  try {
129
+ if (!isAllowed(req))
130
+ return sendError(res, 403, "forbidden");
74
131
  const url = new URL(req.url ?? "/", `http://${host}`);
75
132
  if (url.pathname === "/mcp")
76
133
  return handleMcp(req, res);
@@ -96,9 +153,12 @@ export async function startServer(opts) {
96
153
  return sendError(res, 400, err instanceof Error ? err.message : "invalid project");
97
154
  }
98
155
  }
99
- // static assets (served from public/ on the filesystem)
156
+ // static assets (served from public/ on the filesystem) — resolve and
157
+ // assert the path stays within publicDir before reading.
100
158
  const rel = url.pathname === "/" ? "index.html" : url.pathname.replace(/^\/+/, "");
101
- if (rel.includes(".."))
159
+ const root = resolve(publicDir);
160
+ const full = resolve(root, rel);
161
+ if (full !== root && !full.startsWith(root + sep))
102
162
  return sendError(res, 403, "forbidden");
103
163
  const file = await loadAsset(rel, publicDir);
104
164
  if (!file)
@@ -110,7 +170,10 @@ export async function startServer(opts) {
110
170
  sendError(res, 500, "error");
111
171
  }
112
172
  });
113
- wss = new WebSocketServer({ server: http });
173
+ wss = new WebSocketServer({
174
+ server: http,
175
+ verifyClient: (info) => isAllowed(info.req),
176
+ });
114
177
  wss.on("connection", async (ws) => {
115
178
  const roadmap = getRoadmap();
116
179
  if (roadmap)
@@ -123,11 +186,14 @@ export async function startServer(opts) {
123
186
  /* project list optional */
124
187
  }
125
188
  });
126
- await new Promise((resolve) => http.listen(port, host, resolve));
189
+ await new Promise((ready) => http.listen(port, host, ready));
190
+ const actualPort = http.address()?.port ?? port;
191
+ setAllowed(actualPort);
127
192
  return {
128
- port,
193
+ port: actualPort,
129
194
  broadcast,
130
195
  close: () => {
196
+ clearInterval(idleSweep);
131
197
  wss.close();
132
198
  http.close();
133
199
  },
@@ -141,12 +207,32 @@ function sendError(res, code, message) {
141
207
  res.writeHead(code, { "content-type": MIME[".json"] });
142
208
  res.end(JSON.stringify({ error: message }));
143
209
  }
210
+ /** Raised when a request body exceeds the cap; mapped to HTTP 413. */
211
+ class PayloadTooLargeError extends Error {
212
+ }
213
+ /** Largest request body we will buffer (JSON-RPC tool calls are tiny). */
214
+ const MAX_BODY_BYTES = 4 * 1024 * 1024;
144
215
  /** Read and JSON-parse a request body; returns undefined for an empty body. */
145
216
  function readJsonBody(req) {
146
217
  return new Promise((resolve, reject) => {
147
218
  let data = "";
148
- req.on("data", (chunk) => (data += chunk));
219
+ let size = 0;
220
+ let aborted = false;
221
+ req.on("data", (chunk) => {
222
+ if (aborted)
223
+ return;
224
+ size += chunk.length;
225
+ if (size > MAX_BODY_BYTES) {
226
+ aborted = true;
227
+ reject(new PayloadTooLargeError("request body too large"));
228
+ req.destroy();
229
+ return;
230
+ }
231
+ data += chunk;
232
+ });
149
233
  req.on("end", () => {
234
+ if (aborted)
235
+ return;
150
236
  if (!data)
151
237
  return resolve(undefined);
152
238
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lyric_dev/data-loom",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "Local dashboard for spec-driven development: a phased OpenSpec roadmap (WHAT) and an MCP topology (HOW).",
5
5
  "type": "module",
6
6
  "license": "MIT",