neural-loom 0.2.69 → 0.3.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.
Files changed (161) hide show
  1. package/.next/BUILD_ID +1 -1
  2. package/.next/build-manifest.json +3 -3
  3. package/.next/cache/.previewinfo +1 -1
  4. package/.next/cache/.rscinfo +1 -1
  5. package/.next/cache/.tsbuildinfo +1 -1
  6. package/.next/diagnostics/route-bundle-stats.json +11 -11
  7. package/.next/fallback-build-manifest.json +3 -3
  8. package/.next/next-server.js.nft.json +1 -1
  9. package/.next/prerender-manifest.json +3 -3
  10. package/.next/required-server-files.js +20 -20
  11. package/.next/required-server-files.json +20 -20
  12. package/.next/server/app/_global-error.html +1 -1
  13. package/.next/server/app/_global-error.rsc +1 -1
  14. package/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
  15. package/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  16. package/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  17. package/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  18. package/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  19. package/.next/server/app/_not-found.html +1 -1
  20. package/.next/server/app/_not-found.rsc +1 -1
  21. package/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  22. package/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  23. package/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  24. package/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  25. package/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  26. package/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  27. package/.next/server/app/api/configs/route.js.nft.json +1 -1
  28. package/.next/server/app/api/context/aider/route.js.nft.json +1 -1
  29. package/.next/server/app/api/context/claude/route.js.nft.json +1 -1
  30. package/.next/server/app/api/context/route.js.nft.json +1 -1
  31. package/.next/server/app/api/context/ssh/route.js.nft.json +1 -1
  32. package/.next/server/app/api/files/route.js +1 -1
  33. package/.next/server/app/api/files/route.js.nft.json +1 -1
  34. package/.next/server/app/api/recents/route.js.nft.json +1 -1
  35. package/.next/server/app/api/sessions/inject/route.js.nft.json +1 -1
  36. package/.next/server/app/api/sessions/input/route.js.nft.json +1 -1
  37. package/.next/server/app/api/sessions/launch/route.js.nft.json +1 -1
  38. package/.next/server/app/api/sessions/route.js.nft.json +1 -1
  39. package/.next/server/app/api/sessions/scope/route.js.nft.json +1 -1
  40. package/.next/server/app/api/sessions/start/route.js.nft.json +1 -1
  41. package/.next/server/app/api/sessions/stats/route.js.nft.json +1 -1
  42. package/.next/server/app/api/sessions/stop/route.js.nft.json +1 -1
  43. package/.next/server/app/api/sessions/upload-image/route.js.nft.json +1 -1
  44. package/.next/server/app/api/sql/connections/route.js.nft.json +1 -1
  45. package/.next/server/app/api/sql/mcp/route.js +1 -1
  46. package/.next/server/app/api/sql/mcp/route.js.nft.json +1 -1
  47. package/.next/server/app/api/sql/query/route.js +1 -1
  48. package/.next/server/app/api/sql/query/route.js.nft.json +1 -1
  49. package/.next/server/app/api/sql/schema/route.js.nft.json +1 -1
  50. package/.next/server/app/api/telemetry/route.js +1 -1
  51. package/.next/server/app/api/telemetry/route.js.nft.json +1 -1
  52. package/.next/server/app/index.html +1 -1
  53. package/.next/server/app/index.rsc +2 -2
  54. package/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
  55. package/.next/server/app/index.segments/_full.segment.rsc +2 -2
  56. package/.next/server/app/index.segments/_head.segment.rsc +1 -1
  57. package/.next/server/app/index.segments/_index.segment.rsc +1 -1
  58. package/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  59. package/.next/server/app/page/react-loadable-manifest.json +3 -3
  60. package/.next/server/app/page_client-reference-manifest.js +1 -1
  61. package/.next/server/chunks/[root-of-the-server]__0.hmw1f._.js +3 -0
  62. package/.next/server/chunks/[root-of-the-server]__0.hmw1f._.js.map +1 -0
  63. package/.next/server/chunks/[root-of-the-server]__00-sqou._.js +2 -2
  64. package/.next/server/chunks/[root-of-the-server]__00-sqou._.js.map +1 -1
  65. package/.next/server/chunks/[root-of-the-server]__00l5btc._.js +4 -4
  66. package/.next/server/chunks/[root-of-the-server]__00l5btc._.js.map +1 -1
  67. package/.next/server/chunks/[root-of-the-server]__06men8g._.js +4 -4
  68. package/.next/server/chunks/[root-of-the-server]__06men8g._.js.map +1 -1
  69. package/.next/server/chunks/[root-of-the-server]__097vtn1._.js +2 -2
  70. package/.next/server/chunks/[root-of-the-server]__097vtn1._.js.map +1 -1
  71. package/.next/server/chunks/[root-of-the-server]__0_v2.a8._.js +2 -2
  72. package/.next/server/chunks/[root-of-the-server]__0_v2.a8._.js.map +1 -1
  73. package/.next/server/chunks/{[root-of-the-server]__0.ptwor._.js → [root-of-the-server]__0ajln56._.js} +7 -7
  74. package/.next/server/chunks/[root-of-the-server]__0ajln56._.js.map +1 -0
  75. package/.next/server/chunks/{[root-of-the-server]__0co.g5k._.js → [root-of-the-server]__0auh-2z._.js} +5 -5
  76. package/.next/server/chunks/[root-of-the-server]__0auh-2z._.js.map +1 -0
  77. package/.next/server/chunks/[root-of-the-server]__0e03ite._.js +2 -2
  78. package/.next/server/chunks/[root-of-the-server]__0e03ite._.js.map +1 -1
  79. package/.next/server/chunks/[root-of-the-server]__0fxtx7z._.js +2 -2
  80. package/.next/server/chunks/[root-of-the-server]__0fxtx7z._.js.map +1 -1
  81. package/.next/server/chunks/[root-of-the-server]__0h.~hv6._.js +2 -2
  82. package/.next/server/chunks/[root-of-the-server]__0h.~hv6._.js.map +1 -1
  83. package/.next/server/chunks/[root-of-the-server]__0i83~95._.js +1 -1
  84. package/.next/server/chunks/[root-of-the-server]__0li1h1u._.js +19 -19
  85. package/.next/server/chunks/[root-of-the-server]__0li1h1u._.js.map +1 -1
  86. package/.next/server/chunks/[root-of-the-server]__0s1axek._.js +5 -5
  87. package/.next/server/chunks/[root-of-the-server]__0s1axek._.js.map +1 -1
  88. package/.next/server/chunks/[root-of-the-server]__0t713sv._.js +1 -1
  89. package/.next/server/chunks/[root-of-the-server]__0t713sv._.js.map +1 -1
  90. package/.next/server/chunks/[root-of-the-server]__0tq0maq._.js +5 -5
  91. package/.next/server/chunks/[root-of-the-server]__0tq0maq._.js.map +1 -1
  92. package/.next/server/chunks/[root-of-the-server]__0viil6b._.js +2 -2
  93. package/.next/server/chunks/[root-of-the-server]__0viil6b._.js.map +1 -1
  94. package/.next/server/chunks/[root-of-the-server]__0ykd4~u._.js +3 -0
  95. package/.next/server/chunks/[root-of-the-server]__0ykd4~u._.js.map +1 -0
  96. package/.next/server/chunks/[root-of-the-server]__11h_nxt._.js +1 -1
  97. package/.next/server/chunks/[root-of-the-server]__11h_nxt._.js.map +1 -1
  98. package/.next/server/chunks/node_modules_next_dist_esm_build_templates_app-route_016qk~0.js +2 -2
  99. package/.next/server/chunks/node_modules_next_dist_esm_build_templates_app-route_016qk~0.js.map +1 -1
  100. package/.next/server/chunks/node_modules_next_dist_esm_build_templates_app-route_0ev3h.z.js +1 -1
  101. package/.next/server/chunks/node_modules_next_dist_esm_build_templates_app-route_0ev3h.z.js.map +1 -1
  102. package/.next/server/edge/chunks/[root-of-the-server]__08ca2jv._.js +11 -0
  103. package/.next/server/edge/chunks/[root-of-the-server]__08ca2jv._.js.map +1 -0
  104. package/.next/server/edge/chunks/node_modules_next_dist_esm_build_templates_edge-wrapper_0kvehva.js +3 -0
  105. package/.next/server/edge/chunks/node_modules_next_dist_esm_build_templates_edge-wrapper_0kvehva.js.map +1 -0
  106. package/.next/server/edge/chunks/node_modules_next_dist_esm_build_templates_edge-wrapper_0lcpsxv.js.map +1 -0
  107. package/.next/server/edge/chunks/turbopack-node_modules_next_dist_esm_build_templates_edge-wrapper_0lcpsxv.js +3 -0
  108. package/.next/server/middleware/middleware-manifest.json +32 -0
  109. package/.next/server/middleware-build-manifest.js +3 -3
  110. package/.next/server/middleware-manifest.json +30 -2
  111. package/.next/server/pages/404.html +1 -1
  112. package/.next/server/pages/500.html +1 -1
  113. package/.next/server/server-reference-manifest.js +1 -1
  114. package/.next/server/server-reference-manifest.json +1 -1
  115. package/.next/static/04j4ht7eCOOXSLQ5Wixq0/_clientMiddlewareManifest.js +6 -0
  116. package/.next/static/chunks/{008bf~h8rh5_3.js → 0uyuco-451j.n.js} +2 -2
  117. package/.next/static/chunks/11c6f8pb_6s91.js +4 -0
  118. package/.next/static/chunks/{00pgauqedvo01.js → 14~-ojwsxf~oe.js} +4 -4
  119. package/.next/trace +1 -1
  120. package/.next/trace-build +1 -1
  121. package/README.md +18 -0
  122. package/bin/cli.js +12 -2
  123. package/package.json +9 -6
  124. package/src/app/api/files/route.ts +30 -0
  125. package/src/app/api/git/route.ts +46 -31
  126. package/src/app/api/sessions/stats/route.ts +40 -22
  127. package/src/app/api/sql/query/route.ts +2 -15
  128. package/src/app/api/telemetry/route.ts +2 -1
  129. package/src/app/components/MarkdownPreview.tsx +17 -10
  130. package/src/app/components/TerminalConsole.tsx +99 -21
  131. package/src/lib/agents/AgentRunner.ts +41 -5
  132. package/src/lib/agents/AiderRunner.ts +20 -36
  133. package/src/lib/agents/ClaudeRunner.ts +8 -62
  134. package/src/lib/agents/DockerRunner.ts +18 -54
  135. package/src/lib/agents/MockRunner.ts +3 -1
  136. package/src/lib/agents/SSHRunner.ts +8 -54
  137. package/src/lib/agents/SessionPersister.ts +11 -1
  138. package/src/lib/agents/ShellRunner.ts +10 -46
  139. package/src/lib/agents/WebSocketServer.ts +17 -2
  140. package/src/lib/agents/pricing.ts +45 -0
  141. package/src/lib/agents/runtime.ts +92 -0
  142. package/src/lib/gitSafe.ts +15 -0
  143. package/src/lib/security.ts +76 -0
  144. package/src/lib/sql/McpServer.ts +2 -11
  145. package/src/lib/sql/adapters/MssqlAdapter.ts +16 -1
  146. package/src/lib/sql/adapters/MysqlAdapter.ts +23 -1
  147. package/src/lib/sql/adapters/PostgresAdapter.ts +22 -1
  148. package/src/lib/sql/adapters/SqliteAdapter.ts +21 -10
  149. package/src/lib/sql/sqlGuard.ts +20 -0
  150. package/src/lib/workspaceParser.ts +23 -6
  151. package/src/middleware.ts +27 -0
  152. package/.next/server/chunks/[externals]__048y_op._.js +0 -3
  153. package/.next/server/chunks/[externals]__048y_op._.js.map +0 -1
  154. package/.next/server/chunks/[root-of-the-server]__0.ptwor._.js.map +0 -1
  155. package/.next/server/chunks/[root-of-the-server]__08r0s-s._.js +0 -3
  156. package/.next/server/chunks/[root-of-the-server]__08r0s-s._.js.map +0 -1
  157. package/.next/server/chunks/[root-of-the-server]__0co.g5k._.js.map +0 -1
  158. package/.next/static/EglcK14vXeSWIjxjJCP9A/_clientMiddlewareManifest.js +0 -1
  159. package/.next/static/chunks/0ii_nb0g40rmj.js +0 -4
  160. /package/.next/static/{EglcK14vXeSWIjxjJCP9A → 04j4ht7eCOOXSLQ5Wixq0}/_buildManifest.js +0 -0
  161. /package/.next/static/{EglcK14vXeSWIjxjJCP9A → 04j4ht7eCOOXSLQ5Wixq0}/_ssgManifest.js +0 -0
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Model-keyed pricing used to estimate session cost when the agent's own output
3
+ * doesn't report a dollar figure. Rates are USD per token (list price / 1e6).
4
+ * These are approximate and should be updated as provider pricing changes.
5
+ */
6
+ export interface ModelRate {
7
+ input: number;
8
+ output: number;
9
+ }
10
+
11
+ const M = 1_000_000;
12
+
13
+ const MODEL_RATES: Record<string, ModelRate> = {
14
+ // Anthropic Claude (per 1M tokens: input / output)
15
+ "claude-opus-4": { input: 15 / M, output: 75 / M },
16
+ "claude-sonnet-4": { input: 3 / M, output: 15 / M },
17
+ "claude-haiku-4": { input: 1 / M, output: 5 / M },
18
+ "claude-3-5-sonnet": { input: 3 / M, output: 15 / M },
19
+ "claude-3-5-haiku": { input: 0.8 / M, output: 4 / M },
20
+ "claude-3-opus": { input: 15 / M, output: 75 / M },
21
+ // OpenAI (for Aider users)
22
+ "gpt-4o": { input: 2.5 / M, output: 10 / M },
23
+ "gpt-4o-mini": { input: 0.15 / M, output: 0.6 / M },
24
+ "gpt-4-turbo": { input: 10 / M, output: 30 / M },
25
+ };
26
+
27
+ /** Default when the model is unknown — a mid-tier Claude rate. */
28
+ export const DEFAULT_MODEL = "claude-sonnet-4";
29
+
30
+ export function getModelRate(model?: string): ModelRate {
31
+ if (model) {
32
+ const key = model.toLowerCase();
33
+ if (MODEL_RATES[key]) return MODEL_RATES[key];
34
+ // Substring match so e.g. "claude-sonnet-4-6" or "anthropic/claude-3-5-sonnet" resolve.
35
+ for (const k of Object.keys(MODEL_RATES)) {
36
+ if (key.includes(k)) return MODEL_RATES[k];
37
+ }
38
+ }
39
+ return MODEL_RATES[DEFAULT_MODEL];
40
+ }
41
+
42
+ export function estimateCost(inputTokens: number, outputTokens: number, model?: string): number {
43
+ const rate = getModelRate(model);
44
+ return inputTokens * rate.input + outputTokens * rate.output;
45
+ }
@@ -0,0 +1,92 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import os from "os";
4
+ import { getConfigDir } from "../config";
5
+
6
+ /** Shared runtime helpers used by the PTY-backed runners. */
7
+
8
+ export function isWindows(): boolean {
9
+ return typeof process !== "undefined" && process.platform === "win32";
10
+ }
11
+
12
+ /**
13
+ * Locate a Git Bash executable. Returns "bash" on non-Windows. On Windows,
14
+ * probes the usual install locations and returns null if none are found.
15
+ */
16
+ export function findGitBash(): string | null {
17
+ if (!isWindows()) {
18
+ return "bash";
19
+ }
20
+ const pf = process.env.PROGRAMFILES || "C:\\Program Files";
21
+ const pf86 = process.env["PROGRAMFILES(X86)"] || "C:\\Program Files (x86)";
22
+ const local = process.env.LOCALAPPDATA || "";
23
+
24
+ const candidates: string[] = [
25
+ process.env.WINGMAN_BASH_PATH,
26
+ path.join(pf, "Git", "bin", "bash.exe"),
27
+ path.join(pf86, "Git", "bin", "bash.exe"),
28
+ local ? path.join(local, "Programs", "Git", "bin", "bash.exe") : "",
29
+ ].filter((p): p is string => typeof p === "string" && p !== "");
30
+
31
+ return candidates.find((p) => fs.existsSync(p)) || null;
32
+ }
33
+
34
+ /**
35
+ * Like findGitBash, but for an interactive workspace shell: on Windows, falls
36
+ * back to PowerShell and then cmd.exe when Git Bash is unavailable.
37
+ */
38
+ export function findInteractiveShell(): string | null {
39
+ const bash = findGitBash();
40
+ if (bash) return bash;
41
+ if (!isWindows()) return null;
42
+
43
+ const windir = process.env.windir || "C:\\Windows";
44
+ const powershell = path.join(windir, "System32", "WindowsPowerShell", "v1.0", "powershell.exe");
45
+ if (fs.existsSync(powershell)) return powershell;
46
+
47
+ const cmd = path.join(windir, "System32", "cmd.exe");
48
+ if (fs.existsSync(cmd)) return cmd;
49
+
50
+ return null;
51
+ }
52
+
53
+ let cachedPty: typeof import("node-pty") | null = null;
54
+
55
+ /**
56
+ * Load node-pty at runtime. Uses an indirected require so the Next/Turbopack
57
+ * bundler does not try to statically analyse the native module (it is also
58
+ * declared in serverExternalPackages). Cached after first load.
59
+ */
60
+ export function loadNodePty(): typeof import("node-pty") {
61
+ if (!cachedPty) {
62
+ cachedPty = eval("require")("node-pty") as typeof import("node-pty");
63
+ }
64
+ return cachedPty;
65
+ }
66
+
67
+ /**
68
+ * Resolve extra CLI flags for the `claude` command. `--resume` when resuming,
69
+ * otherwise the `extraFlags` field from the user's claude.json (if any).
70
+ */
71
+ export function loadClaudeExtraFlags(resume?: boolean): string {
72
+ if (resume) return "--resume";
73
+ const claudeConfigPath = path.join(getConfigDir(), "claude.json");
74
+ if (fs.existsSync(claudeConfigPath)) {
75
+ try {
76
+ const config = JSON.parse(fs.readFileSync(claudeConfigPath, "utf8"));
77
+ if (config.extraFlags) return config.extraFlags;
78
+ } catch (err) {
79
+ console.error("Failed to parse Claude configuration:", err);
80
+ }
81
+ }
82
+ return "";
83
+ }
84
+
85
+ /** Build the Windows PATH override (prepends VS Node + global npm). */
86
+ export function windowsPathOverride(): string {
87
+ const vsNode =
88
+ "C:\\Program Files\\Microsoft Visual Studio\\2022\\Community\\MSBuild\\Microsoft\\VisualStudio\\NodeJs";
89
+ const localNpm = path.join(os.homedir(), "AppData", "Local", "npm");
90
+ const existingPath = process.env.Path || process.env.PATH || "";
91
+ return [vsNode, localNpm, existingPath].filter(Boolean).join(";");
92
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Validate a git ref name before passing it to `git`. Even with execFile (no
3
+ * shell), a value starting with `-` could be misread as an option, so we reject
4
+ * those along with git-illegal characters and `..` sequences.
5
+ */
6
+ export function isValidBranchName(name: unknown): name is string {
7
+ return (
8
+ typeof name === "string" &&
9
+ name.length > 0 &&
10
+ name.length <= 255 &&
11
+ !name.startsWith("-") &&
12
+ !name.includes("..") &&
13
+ /^[A-Za-z0-9._\/-]+$/.test(name)
14
+ );
15
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Shared request-origin guard for NeuralLoom.
3
+ *
4
+ * Contains NO Node-only imports so it can run in the Edge middleware runtime,
5
+ * in Node API routes, and in the raw `ws` gateway alike.
6
+ *
7
+ * Default posture is "localhost only": the dashboard, file/git/SQL APIs and the
8
+ * terminal gateway all expose powerful local capabilities (arbitrary shell,
9
+ * filesystem and DB access), so by default we only accept requests whose Host
10
+ * and Origin resolve to the loopback interface. This blocks LAN peers and
11
+ * defends against DNS-rebinding (where a malicious page carries the attacker's
12
+ * domain in Host/Origin while resolving to 127.0.0.1).
13
+ *
14
+ * Set NEURAL_LOOM_ALLOW_LAN=1 to expose the tool on the network. Even then we
15
+ * reject cross-site requests (Origin hostname must match the Host hostname) as a
16
+ * CSRF defense.
17
+ */
18
+
19
+ const LOCAL_HOSTNAMES = new Set(["localhost", "127.0.0.1", "::1", "0.0.0.0"]);
20
+
21
+ /** Extract the bare hostname from a Host header or Origin URL (no scheme/port). */
22
+ export function hostnameOf(value: string | null | undefined): string | null {
23
+ if (!value) return null;
24
+ let v = value.trim();
25
+ if (!v) return null;
26
+ // Strip scheme (Origin headers look like "http://host:port")
27
+ v = v.replace(/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//, "");
28
+ // Strip any path
29
+ v = v.split("/")[0];
30
+ // Bracketed IPv6, e.g. "[::1]:9601"
31
+ if (v.startsWith("[")) {
32
+ const end = v.indexOf("]");
33
+ return (end >= 0 ? v.slice(1, end) : v).toLowerCase();
34
+ }
35
+ // Strip ":port"
36
+ const colon = v.lastIndexOf(":");
37
+ if (colon >= 0) v = v.slice(0, colon);
38
+ return v.toLowerCase();
39
+ }
40
+
41
+ export function isLanAllowed(): boolean {
42
+ const v = (process.env.NEURAL_LOOM_ALLOW_LAN || "").toLowerCase();
43
+ return v === "1" || v === "true" || v === "yes";
44
+ }
45
+
46
+ export function isLocalHostname(hostname: string | null): boolean {
47
+ return !!hostname && LOCAL_HOSTNAMES.has(hostname);
48
+ }
49
+
50
+ /** The interface the HTTP/WS servers should bind to given the current posture. */
51
+ export function getBindHost(): string {
52
+ return process.env.NEURAL_LOOM_HOST || (isLanAllowed() ? "0.0.0.0" : "127.0.0.1");
53
+ }
54
+
55
+ /**
56
+ * Decide whether an HTTP/WS request is allowed based on its Host and Origin
57
+ * headers. See module docs for the policy.
58
+ */
59
+ export function isRequestAllowed(
60
+ hostHeader: string | null,
61
+ originHeader: string | null
62
+ ): boolean {
63
+ const host = hostnameOf(hostHeader);
64
+ const origin = hostnameOf(originHeader);
65
+
66
+ if (!isLanAllowed()) {
67
+ if (!isLocalHostname(host)) return false;
68
+ // A present Origin must also be loopback (blocks rebinding/cross-site).
69
+ if (originHeader && !isLocalHostname(origin)) return false;
70
+ return true;
71
+ }
72
+
73
+ // LAN explicitly allowed: still block obvious cross-site requests.
74
+ if (originHeader && origin !== host) return false;
75
+ return true;
76
+ }
@@ -1,6 +1,7 @@
1
1
  /* eslint-disable @typescript-eslint/no-explicit-any */
2
2
  import readline from "readline";
3
3
  import { ConnectionManager } from "./ConnectionManager";
4
+ import { isReadOnlyViolation } from "./sqlGuard";
4
5
 
5
6
  export class McpServer {
6
7
  private workspaceRoot?: string;
@@ -232,17 +233,7 @@ export class McpServer {
232
233
  return;
233
234
  }
234
235
 
235
- const clean = sql.trim().toLowerCase();
236
- const isMutation = clean.startsWith("insert") ||
237
- clean.startsWith("update") ||
238
- clean.startsWith("delete") ||
239
- clean.startsWith("drop") ||
240
- clean.startsWith("alter") ||
241
- clean.startsWith("create") ||
242
- clean.startsWith("truncate") ||
243
- clean.startsWith("replace");
244
-
245
- if (profile.readOnly && isMutation) {
236
+ if (profile.readOnly && isReadOnlyViolation(sql)) {
246
237
  this.sendToolResult(id, "Error: Access Denied. This database connection is configured as Read-Only.", true);
247
238
  return;
248
239
  }
@@ -41,8 +41,23 @@ export class MssqlAdapter extends DbAdapter {
41
41
  }
42
42
  }
43
43
 
44
+ // Validate the cached connection before use; transparently reconnect if the
45
+ // server dropped it (the pooled adapter instance is long-lived).
46
+ private async ensureLive(): Promise<void> {
47
+ if (!this.pool) {
48
+ await this.connect();
49
+ return;
50
+ }
51
+ try {
52
+ await this.pool.request().query("SELECT 1");
53
+ } catch {
54
+ try { await this.disconnect(); } catch { /* ignore */ }
55
+ await this.connect();
56
+ }
57
+ }
58
+
44
59
  async query(sql: string, params: any[] = []): Promise<QueryResult> {
45
- await this.connect();
60
+ await this.ensureLive();
46
61
  try {
47
62
  const request = this.pool.request();
48
63
 
@@ -31,6 +31,13 @@ export class MysqlAdapter extends DbAdapter {
31
31
  password: this.config.password || "",
32
32
  ssl: this.config.ssl ? this.config.ssl : undefined
33
33
  });
34
+
35
+ // Enforce read-only at the session level. With autocommit each statement
36
+ // runs in its own read-only transaction, so writes are rejected by the
37
+ // server rather than relying solely on our SQL guard.
38
+ if (this.config.readOnly) {
39
+ await this.connection.query("SET SESSION TRANSACTION READ ONLY");
40
+ }
34
41
  } catch (err: any) {
35
42
  console.error("[MysqlAdapter] Connect failed:", err);
36
43
  this.connection = null;
@@ -38,8 +45,23 @@ export class MysqlAdapter extends DbAdapter {
38
45
  }
39
46
  }
40
47
 
48
+ // Validate the cached connection before use; transparently reconnect if the
49
+ // server dropped it (the pooled adapter instance is long-lived).
50
+ private async ensureLive(): Promise<void> {
51
+ if (!this.connection) {
52
+ await this.connect();
53
+ return;
54
+ }
55
+ try {
56
+ await this.connection.query("SELECT 1");
57
+ } catch {
58
+ try { await this.disconnect(); } catch { /* ignore */ }
59
+ await this.connect();
60
+ }
61
+ }
62
+
41
63
  async query(sql: string, params: any[] = []): Promise<QueryResult> {
42
- await this.connect();
64
+ await this.ensureLive();
43
65
  try {
44
66
  const [rows, fields] = await this.connection.execute(sql, params);
45
67
 
@@ -34,6 +34,12 @@ export class PostgresAdapter extends DbAdapter {
34
34
  });
35
35
 
36
36
  await this.client.connect();
37
+
38
+ // Enforce read-only at the session level so every subsequent statement
39
+ // (including CTEs and stacked statements) is rejected by the server.
40
+ if (this.config.readOnly) {
41
+ await this.client.query("SET SESSION CHARACTERISTICS AS TRANSACTION READ ONLY");
42
+ }
37
43
  } catch (err: any) {
38
44
  console.error("[PostgresAdapter] Connect failed:", err);
39
45
  this.client = null;
@@ -41,8 +47,23 @@ export class PostgresAdapter extends DbAdapter {
41
47
  }
42
48
  }
43
49
 
50
+ // Validate the cached connection before use; transparently reconnect if the
51
+ // server dropped it (the pooled adapter instance is long-lived).
52
+ private async ensureLive(): Promise<void> {
53
+ if (!this.client) {
54
+ await this.connect();
55
+ return;
56
+ }
57
+ try {
58
+ await this.client.query("SELECT 1");
59
+ } catch {
60
+ try { await this.disconnect(); } catch { /* ignore */ }
61
+ await this.connect();
62
+ }
63
+ }
64
+
44
65
  async query(sql: string, params: any[] = []): Promise<QueryResult> {
45
- await this.connect();
66
+ await this.ensureLive();
46
67
  try {
47
68
  const res = await this.client.query(sql, params);
48
69
  const rows = res.rows || [];
@@ -21,11 +21,13 @@ export class SqliteAdapter extends DbAdapter {
21
21
 
22
22
  async connect(): Promise<void> {
23
23
  if (this.db) return;
24
-
25
- // Ensure parent directory exists
26
- const dir = path.dirname(this.resolvedPath);
27
- if (!fs.existsSync(dir)) {
28
- fs.mkdirSync(dir, { recursive: true });
24
+
25
+ // Ensure parent directory exists (only when we may create/write the file).
26
+ if (!this.config.readOnly) {
27
+ const dir = path.dirname(this.resolvedPath);
28
+ if (!fs.existsSync(dir)) {
29
+ fs.mkdirSync(dir, { recursive: true });
30
+ }
29
31
  }
30
32
 
31
33
  try {
@@ -39,17 +41,26 @@ export class SqliteAdapter extends DbAdapter {
39
41
  );
40
42
  }
41
43
 
42
- this.db = new Database(this.resolvedPath);
44
+ // Read-only profiles open the database with the driver's readonly flag, so
45
+ // any write attempt fails at the engine level (not just our SQL guard).
46
+ this.db = new Database(this.resolvedPath, this.config.readOnly ? { readonly: true } : {});
43
47
  } catch (err: any) {
44
48
  console.error("[SqliteAdapter] Connect failed:", err);
45
49
  throw err;
46
50
  }
47
51
  }
48
52
 
49
- async query(sql: string, params: any[] = []): Promise<QueryResult> {
53
+ // Reopen the database handle if it was closed (e.g. after a disconnect).
54
+ private async ensureLive(): Promise<void> {
55
+ if (this.db && this.db.open) return;
56
+ this.db = null;
50
57
  await this.connect();
58
+ }
59
+
60
+ async query(sql: string, params: any[] = []): Promise<QueryResult> {
61
+ await this.ensureLive();
51
62
  try {
52
- const isSelect = sql.trim().toLowerCase().startsWith("select") ||
63
+ const isSelect = sql.trim().toLowerCase().startsWith("select") ||
53
64
  sql.trim().toLowerCase().startsWith("pragma") ||
54
65
  sql.trim().toLowerCase().startsWith("explain");
55
66
 
@@ -80,8 +91,8 @@ export class SqliteAdapter extends DbAdapter {
80
91
  }
81
92
 
82
93
  async describeTable(tableName: string): Promise<ColumnSchema[]> {
83
- await this.connect();
84
-
94
+ await this.ensureLive();
95
+
85
96
  // Get table columns
86
97
  const columnsInfo = this.db.prepare(`PRAGMA table_info(\`${tableName}\`)`).all();
87
98
  // Get foreign keys
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Backstop guard for read-only DB connections.
3
+ *
4
+ * Real enforcement happens at the driver/transaction level (SQLite opened
5
+ * read-only, Postgres/MySQL session set READ ONLY). This statement inspection is
6
+ * a defense-in-depth layer and the only available guard for drivers without a
7
+ * session read-only mode (e.g. MSSQL). It strips SQL comments first so a leading
8
+ * comment trick can't hide a mutation, and matches mutation keywords anywhere
9
+ * (covering CTEs such as `WITH x AS (...) DELETE ...` and stacked statements
10
+ * like `SELECT 1; DROP TABLE t`). It intentionally errs toward over-blocking.
11
+ */
12
+ export function isReadOnlyViolation(sql: string): boolean {
13
+ if (!sql) return false;
14
+ const stripped = sql
15
+ .replace(/\/\*[\s\S]*?\*\//g, " ") // block comments
16
+ .replace(/--[^\n]*/g, " "); // line comments
17
+ return /\b(insert|update|delete|drop|alter|create|truncate|replace|grant|revoke|merge|exec|execute)\b/i.test(
18
+ stripped
19
+ );
20
+ }
@@ -1,29 +1,45 @@
1
1
  import fs from "fs";
2
2
  import path from "path";
3
3
 
4
+ // Cache parsed folders per workspace root, keyed off the .code-workspace file's
5
+ // mtime so repeated polling doesn't re-read and re-parse it every call.
6
+ const folderCache = new Map<string, { mtimeMs: number; folders: string[] }>();
7
+
4
8
  export function getWorkspaceFileFolders(workspaceRoot: string): string[] {
5
9
  try {
6
10
  if (!workspaceRoot || !fs.existsSync(workspaceRoot)) return [];
7
-
11
+
8
12
  // Normalize workspaceRoot path
9
13
  const normRoot = path.resolve(workspaceRoot).replace(/\\/g, "/");
10
-
14
+
11
15
  const files = fs.readdirSync(normRoot);
12
16
  const workspaceFile = files.find(f => f.endsWith(".code-workspace"));
13
- if (!workspaceFile) return [];
17
+ if (!workspaceFile) {
18
+ folderCache.delete(normRoot);
19
+ return [];
20
+ }
14
21
 
15
22
  const filePath = path.join(normRoot, workspaceFile);
23
+ const mtimeMs = fs.statSync(filePath).mtimeMs;
24
+ const cached = folderCache.get(normRoot);
25
+ if (cached && cached.mtimeMs === mtimeMs) {
26
+ return cached.folders;
27
+ }
28
+
16
29
  const content = fs.readFileSync(filePath, "utf8");
17
30
  const json = JSON.parse(content);
18
31
 
19
- if (!json.folders || !Array.isArray(json.folders)) return [];
20
-
32
+ if (!json.folders || !Array.isArray(json.folders)) {
33
+ folderCache.set(normRoot, { mtimeMs, folders: [] });
34
+ return [];
35
+ }
36
+
21
37
  const folders: string[] = [];
22
38
  for (const folder of json.folders) {
23
39
  if (folder.path) {
24
40
  // Resolve relative to workspace root (where the workspace file resides)
25
41
  const resolved = path.resolve(normRoot, folder.path).replace(/\\/g, "/");
26
-
42
+
27
43
  // Exclude the workspace root folder itself
28
44
  if (resolved !== normRoot) {
29
45
  if (fs.existsSync(resolved)) {
@@ -32,6 +48,7 @@ export function getWorkspaceFileFolders(workspaceRoot: string): string[] {
32
48
  }
33
49
  }
34
50
  }
51
+ folderCache.set(normRoot, { mtimeMs, folders });
35
52
  return folders;
36
53
  } catch (err) {
37
54
  console.error("[WorkspaceParser] Failed to parse code-workspace file:", err);
@@ -0,0 +1,27 @@
1
+ import { NextResponse } from "next/server";
2
+ import type { NextRequest } from "next/server";
3
+ import { isRequestAllowed } from "@/lib/security";
4
+
5
+ // Gate every API route behind the localhost-origin guard. The dashboard's APIs
6
+ // expose arbitrary shell, filesystem and database access, so cross-origin / LAN
7
+ // callers are rejected unless NEURAL_LOOM_ALLOW_LAN is explicitly set.
8
+ export function middleware(request: NextRequest) {
9
+ const host = request.headers.get("host");
10
+ const origin = request.headers.get("origin");
11
+
12
+ if (!isRequestAllowed(host, origin)) {
13
+ return NextResponse.json(
14
+ {
15
+ error:
16
+ "Forbidden: NeuralLoom only accepts requests from localhost. Set NEURAL_LOOM_ALLOW_LAN=1 to permit network access.",
17
+ },
18
+ { status: 403 }
19
+ );
20
+ }
21
+
22
+ return NextResponse.next();
23
+ }
24
+
25
+ export const config = {
26
+ matcher: "/api/:path*",
27
+ };
@@ -1,3 +0,0 @@
1
- module.exports=[93695,(e,r,t)=>{r.exports=e.x("next/dist/shared/lib/no-fallback-error.external.js",()=>require("next/dist/shared/lib/no-fallback-error.external.js"))},18622,(e,r,t)=>{r.exports=e.x("next/dist/compiled/next-server/app-page-turbo.runtime.prod.js",()=>require("next/dist/compiled/next-server/app-page-turbo.runtime.prod.js"))},56704,(e,r,t)=>{r.exports=e.x("next/dist/server/app-render/work-async-storage.external.js",()=>require("next/dist/server/app-render/work-async-storage.external.js"))},32319,(e,r,t)=>{r.exports=e.x("next/dist/server/app-render/work-unit-async-storage.external.js",()=>require("next/dist/server/app-render/work-unit-async-storage.external.js"))},24725,(e,r,t)=>{r.exports=e.x("next/dist/server/app-render/after-task-async-storage.external.js",()=>require("next/dist/server/app-render/after-task-async-storage.external.js"))},70406,(e,r,t)=>{r.exports=e.x("next/dist/compiled/@opentelemetry/api",()=>require("next/dist/compiled/@opentelemetry/api"))},14747,(e,r,t)=>{r.exports=e.x("path",()=>require("path"))},46786,(e,r,t)=>{r.exports=e.x("os",()=>require("os"))}];
2
-
3
- //# sourceMappingURL=%5Bexternals%5D__048y_op._.js.map
@@ -1 +0,0 @@
1
- {"version":3,"sources":[],"names":[],"mappings":""}