gitnexus 1.6.6-rc.7 → 1.6.6-rc.8

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.
@@ -66,7 +66,9 @@ export declare const initLbug: (dbPath: string) => Promise<{
66
66
  * database is busy (e.g. `gitnexus analyze` holds the write lock).
67
67
  * Each retry waits DB_LOCK_RETRY_DELAY_MS * attempt milliseconds.
68
68
  */
69
- export declare const withLbugDb: <T>(dbPath: string, operation: () => Promise<T>) => Promise<T>;
69
+ export declare const withLbugDb: <T>(dbPath: string, operation: () => Promise<T>, options?: {
70
+ readOnly?: boolean;
71
+ }) => Promise<T>;
70
72
  export type LbugProgressCallback = (message: string) => void;
71
73
  export declare const loadGraphToLbug: (graph: KnowledgeGraph, repoPath: string, storagePath: string, onProgress?: LbugProgressCallback) => Promise<{
72
74
  success: boolean;
@@ -112,6 +112,7 @@ export const splitRelCsvByLabelPair = async (csvPath, csvDir, validTables, getNo
112
112
  let db = null;
113
113
  let conn = null;
114
114
  let currentDbPath = null;
115
+ let currentDbReadOnly = false;
115
116
  let ftsLoaded = false;
116
117
  let vectorExtensionLoaded = false;
117
118
  /**
@@ -367,12 +368,13 @@ export const initLbug = async (dbPath) => {
367
368
  * database is busy (e.g. `gitnexus analyze` holds the write lock).
368
369
  * Each retry waits DB_LOCK_RETRY_DELAY_MS * attempt milliseconds.
369
370
  */
370
- export const withLbugDb = async (dbPath, operation) => {
371
+ export const withLbugDb = async (dbPath, operation, options = {}) => {
371
372
  let lastError;
373
+ const readOnly = options.readOnly === true;
372
374
  for (let attempt = 1; attempt <= DB_LOCK_RETRY_ATTEMPTS; attempt++) {
373
375
  try {
374
376
  return await runWithSessionLock(async () => {
375
- await ensureLbugInitialized(dbPath);
377
+ await ensureLbugInitialized(dbPath, readOnly);
376
378
  return operation();
377
379
  });
378
380
  }
@@ -402,14 +404,14 @@ export const withLbugDb = async (dbPath, operation) => {
402
404
  // but TypeScript needs an explicit throw to satisfy the return type.
403
405
  throw lastError;
404
406
  };
405
- const ensureLbugInitialized = async (dbPath) => {
406
- if (conn && currentDbPath === dbPath) {
407
+ const ensureLbugInitialized = async (dbPath, readOnly = false) => {
408
+ if (conn && currentDbPath === dbPath && currentDbReadOnly === readOnly) {
407
409
  return { db, conn };
408
410
  }
409
- await doInitLbug(dbPath);
411
+ await doInitLbug(dbPath, readOnly);
410
412
  return { db, conn };
411
413
  };
412
- const doInitLbug = async (dbPath) => {
414
+ const doInitLbug = async (dbPath, readOnly = false) => {
413
415
  // Different database requested — close the old one first
414
416
  if (conn || db) {
415
417
  await safeClose();
@@ -486,9 +488,12 @@ const doInitLbug = async (dbPath) => {
486
488
  // Ensure parent directory exists
487
489
  const parentDir = path.dirname(dbPath);
488
490
  await fs.mkdir(parentDir, { recursive: true });
489
- const opened = await openLbugConnection(lbug, dbPath);
491
+ const opened = readOnly
492
+ ? await openLbugConnection(lbug, dbPath, { readOnly: true })
493
+ : await openLbugConnection(lbug, dbPath);
490
494
  db = opened.db;
491
495
  conn = opened.conn;
496
+ currentDbReadOnly = readOnly;
492
497
  }
493
498
  finally {
494
499
  await releaseInitLock();
@@ -524,7 +529,7 @@ const doInitLbug = async (dbPath) => {
524
529
  throw new Error(`LadybugDB WAL corruption detected at ${dbPath}. ${WAL_RECOVERY_SUGGESTION}\n` +
525
530
  ` Original error: ${msg.slice(0, 200)}`);
526
531
  }
527
- if (!msg.includes('already exists') && !isDbBusyError(err)) {
532
+ if (!msg.includes('already exists') && !isDbBusyError(err) && !isReadOnlyDbError(err)) {
528
533
  logger.warn(`⚠️ Schema creation warning: ${msg.slice(0, 120)}`);
529
534
  }
530
535
  }
@@ -931,11 +936,7 @@ export const batchInsertNodesToLbug = async (nodes, dbPath) => {
931
936
  return { inserted, failed };
932
937
  };
933
938
  export const executeQuery = async (cypher) => {
934
- if (!conn) {
935
- throw new Error('LadybugDB not initialized. Call initLbug first.');
936
- }
937
- const queryResult = await conn.query(cypher);
938
- return await readQueryRows(queryResult);
939
+ return await executePrepared(cypher, {});
939
940
  };
940
941
  export const streamQuery = async (cypher, onRow) => {
941
942
  if (!conn) {
@@ -1517,17 +1518,14 @@ export const queryFTS = async (tableName, indexName, query, limit = 20, conjunct
1517
1518
  if (!conn) {
1518
1519
  throw new Error('LadybugDB not initialized. Call initLbug first.');
1519
1520
  }
1520
- // Escape backslashes and single quotes to prevent Cypher injection
1521
- const escapedQuery = query.replace(/\\/g, '\\\\').replace(/'/g, "''");
1522
1521
  const cypher = `
1523
- CALL QUERY_FTS_INDEX('${tableName}', '${indexName}', '${escapedQuery}', conjunctive := ${conjunctive})
1522
+ CALL QUERY_FTS_INDEX('${tableName}', '${indexName}', $query, conjunctive := ${conjunctive})
1524
1523
  RETURN node, score
1525
1524
  ORDER BY score DESC
1526
1525
  LIMIT ${limit}
1527
1526
  `;
1528
1527
  try {
1529
- const queryResult = await conn.query(cypher);
1530
- const rows = await readQueryRows(queryResult);
1528
+ const rows = await executePrepared(cypher, { query });
1531
1529
  return rows.map((row) => {
1532
1530
  const node = row.node || row[0] || {};
1533
1531
  const score = row.score ?? row[1] ?? 0;
@@ -81,10 +81,3 @@ export declare const closeLbug: (repoId?: string) => Promise<void>;
81
81
  * Check if a specific repo's pool is active
82
82
  */
83
83
  export declare const isLbugReady: (repoId: string) => boolean;
84
- /** Regex to detect write operations in user-supplied Cypher queries.
85
- * Note: CALL is NOT blocked — it's used for read-only FTS (CALL QUERY_FTS_INDEX)
86
- * and vector search (CALL QUERY_VECTOR_INDEX). The database is opened in
87
- * read-only mode as defense-in-depth against write procedures. */
88
- export declare const CYPHER_WRITE_RE: RegExp;
89
- /** Check if a Cypher query contains write operations */
90
- export declare function isWriteQuery(query: string): boolean;
@@ -16,7 +16,7 @@
16
16
  */
17
17
  import fs from 'fs/promises';
18
18
  import lbug from '@ladybugdb/core';
19
- import { loadFTSExtension } from './lbug-adapter.js';
19
+ import { isReadOnlyDbError, loadFTSExtension } from './lbug-adapter.js';
20
20
  import { createLbugDatabase, isWalCorruptionError, WAL_RECOVERY_SUGGESTION, } from './lbug-config.js';
21
21
  const pool = new Map();
22
22
  const poolCloseListeners = new Set();
@@ -505,28 +505,7 @@ function withTimeout(promise, ms, label) {
505
505
  return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
506
506
  }
507
507
  export const executeQuery = async (repoId, cypher) => {
508
- const entry = pool.get(repoId);
509
- if (!entry) {
510
- throw new Error(`LadybugDB not initialized for repo "${repoId}". Call initLbug first.`);
511
- }
512
- if (isWriteQuery(cypher)) {
513
- throw new Error('Write operations are not allowed. The pool adapter is read-only.');
514
- }
515
- entry.lastUsed = Date.now();
516
- const conn = await checkout(entry);
517
- silenceStdout();
518
- activeQueryCount++;
519
- try {
520
- const queryResult = await withTimeout(conn.query(cypher), QUERY_TIMEOUT_MS, 'Query');
521
- const result = Array.isArray(queryResult) ? queryResult[0] : queryResult;
522
- const rows = await result.getAll();
523
- return rows;
524
- }
525
- finally {
526
- activeQueryCount--;
527
- restoreStdout();
528
- checkin(entry, conn);
529
- }
508
+ return await executeParameterized(repoId, cypher, {});
530
509
  };
531
510
  /**
532
511
  * Execute a parameterized query on a specific repo's connection pool.
@@ -552,6 +531,12 @@ export const executeParameterized = async (repoId, cypher, params) => {
552
531
  const rows = await result.getAll();
553
532
  return rows;
554
533
  }
534
+ catch (err) {
535
+ if (isReadOnlyDbError(err)) {
536
+ throw new Error('Write operations are not allowed. The pool adapter is read-only.');
537
+ }
538
+ throw err;
539
+ }
555
540
  finally {
556
541
  activeQueryCount--;
557
542
  restoreStdout();
@@ -580,12 +565,3 @@ export const closeLbug = async (repoId) => {
580
565
  * Check if a specific repo's pool is active
581
566
  */
582
567
  export const isLbugReady = (repoId) => pool.has(repoId);
583
- /** Regex to detect write operations in user-supplied Cypher queries.
584
- * Note: CALL is NOT blocked — it's used for read-only FTS (CALL QUERY_FTS_INDEX)
585
- * and vector search (CALL QUERY_VECTOR_INDEX). The database is opened in
586
- * read-only mode as defense-in-depth against write procedures. */
587
- export const CYPHER_WRITE_RE = /(?<!:)\b(CREATE|DELETE|SET|MERGE|REMOVE|DROP|ALTER|COPY|DETACH|FOREACH|INSTALL|LOAD)\b/i;
588
- /** Check if a Cypher query contains write operations */
589
- export function isWriteQuery(query) {
590
- return CYPHER_WRITE_RE.test(query);
591
- }
@@ -0,0 +1 @@
1
+ export declare const isValidQueryParams: (value: unknown) => value is Record<string, unknown>;
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Return true only for plain-object payloads that can be safely used as
3
+ * named parameter maps in prepared Cypher execution.
4
+ *
5
+ * Validation criteria:
6
+ * - must be a JavaScript object (`typeof value === 'object'`)
7
+ * - must not be `null`
8
+ * - must not be an array
9
+ * - must have a plain-object prototype
10
+ * - values must be scalar bindable values (string | number | boolean | null)
11
+ *
12
+ * Rationale: prepared-statement params are key/value maps; rejecting null/array
13
+ * and non-plain objects keeps binding behavior predictable and avoids passing
14
+ * complex host objects to Ladybug parameter binding.
15
+ */
16
+ const isBindableScalar = (value) => value === null || ['string', 'number', 'boolean'].includes(typeof value);
17
+ export const isValidQueryParams = (value) => value !== null &&
18
+ typeof value === 'object' &&
19
+ !Array.isArray(value) &&
20
+ (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null) &&
21
+ Object.values(value).every(isBindableScalar);
@@ -12,16 +12,14 @@ import { FTS_INDEXES } from './fts-schema.js';
12
12
  * caller can distinguish "zero matches" from "index missing".
13
13
  */
14
14
  async function queryFTSViaExecutor(executor, tableName, indexName, query, limit) {
15
- // Escape single quotes and backslashes to prevent Cypher injection
16
- const escapedQuery = query.replace(/\\/g, '\\\\').replace(/'/g, "''");
17
15
  const cypher = `
18
- CALL QUERY_FTS_INDEX('${tableName}', '${indexName}', '${escapedQuery}', conjunctive := false)
16
+ CALL QUERY_FTS_INDEX('${tableName}', '${indexName}', $query, conjunctive := false)
19
17
  RETURN node, score
20
18
  ORDER BY score DESC
21
19
  LIMIT ${limit}
22
20
  `;
23
21
  try {
24
- const rows = await executor(cypher);
22
+ const rows = await executor(cypher, { query });
25
23
  return rows.map((row) => {
26
24
  const node = row.node || row[0] || {};
27
25
  const score = row.score ?? row[1] ?? 0;
@@ -55,8 +53,8 @@ export const searchFTSFromLbug = async (query, limit = 20, repoId) => {
55
53
  // IMPORTANT: FTS queries run sequentially to avoid connection contention.
56
54
  // The MCP pool supports multiple connections, but FTS is best run serially.
57
55
  const poolMod = await import('../lbug/pool-adapter.js');
58
- const { executeQuery } = poolMod;
59
- const executor = (cypher) => executeQuery(repoId, cypher);
56
+ const { executeParameterized } = poolMod;
57
+ const executor = (cypher, params) => executeParameterized(repoId, cypher, params);
60
58
  for (const { table, indexName } of FTS_INDEXES) {
61
59
  const result = await queryFTSViaExecutor(executor, table, indexName, query, limit);
62
60
  if (result !== null) {
@@ -5,8 +5,6 @@
5
5
  * Supports multiple indexed repositories via a global registry.
6
6
  * LadybugDB connections are opened lazily per repo on first query.
7
7
  */
8
- import { isWriteQuery } from '../../core/lbug/pool-adapter.js';
9
- export { isWriteQuery };
10
8
  import { type RegistryEntry } from '../../storage/repo-manager.js';
11
9
  import { GroupService } from '../../core/group/service.js';
12
10
  /**
@@ -208,7 +206,7 @@ export declare class LocalBackend {
208
206
  * Semantic vector search helper
209
207
  */
210
208
  private semanticSearch;
211
- executeCypher(repoName: string, query: string): Promise<any>;
209
+ executeCypher(repoName: string, query: string, params?: Record<string, unknown>): Promise<any>;
212
210
  private cypher;
213
211
  /**
214
212
  * Format raw Cypher result rows as a markdown table for LLM readability.
@@ -370,3 +368,4 @@ export declare class LocalBackend {
370
368
  queryProcessDetail(name: string, repoName?: string): Promise<any>;
371
369
  disconnect(): Promise<void>;
372
370
  }
371
+ export {};
@@ -7,9 +7,9 @@
7
7
  */
8
8
  import fs from 'fs/promises';
9
9
  import path from 'path';
10
- import { initLbug, executeQuery, executeParameterized, closeLbug, isLbugReady, isWriteQuery, } from '../../core/lbug/pool-adapter.js';
10
+ import { initLbug, executeQuery, executeParameterized, closeLbug, isLbugReady, } from '../../core/lbug/pool-adapter.js';
11
+ import { isValidQueryParams } from '../../core/lbug/query-params.js';
11
12
  import { isWalCorruptionError, WAL_RECOVERY_SUGGESTION } from '../../core/lbug/lbug-config.js';
12
- export { isWriteQuery };
13
13
  // Embedding imports are lazy (dynamic import) to avoid loading onnxruntime-node
14
14
  // at MCP server startup — crashes on unsupported Node ABI versions (#89)
15
15
  // git utilities available if needed
@@ -142,6 +142,7 @@ function logQueryError(context, err) {
142
142
  const msg = err instanceof Error ? err.message : String(err);
143
143
  logger.error({ context, err: msg }, 'GitNexus query failed');
144
144
  }
145
+ const isReadOnlyDbError = (err) => /read-only database/i.test(err instanceof Error ? err.message : String(err));
145
146
  /**
146
147
  * Per-query latency telemetry for production aggregation (#553).
147
148
  *
@@ -1067,27 +1068,31 @@ export class LocalBackend {
1067
1068
  return [];
1068
1069
  }
1069
1070
  }
1070
- async executeCypher(repoName, query) {
1071
+ async executeCypher(repoName, query, params = {}) {
1071
1072
  const repo = await this.resolveRepo(repoName);
1072
- return this.cypher(repo, { query });
1073
+ return this.cypher(repo, { query, params });
1073
1074
  }
1074
- async cypher(repo, params) {
1075
+ async cypher(repo, request) {
1075
1076
  await this.ensureInitialized(repo.id);
1076
1077
  if (!isLbugReady(repo.id)) {
1077
1078
  return { error: 'LadybugDB not ready. Index may be corrupted.' };
1078
1079
  }
1079
- // Block write operations (defense-in-depth DB is already read-only)
1080
- if (isWriteQuery(params.query)) {
1080
+ if (request.params !== undefined && !isValidQueryParams(request.params)) {
1081
1081
  return {
1082
- error: 'Write operations (CREATE, DELETE, SET, MERGE, REMOVE, DROP, ALTER, COPY, DETACH) are not allowed. The knowledge graph is read-only.',
1082
+ error: '"params" must be a plain object with scalar values (string/number/boolean/null).',
1083
1083
  };
1084
1084
  }
1085
1085
  try {
1086
- const result = await executeQuery(repo.id, params.query);
1086
+ const result = await executeParameterized(repo.id, request.query, request.params ?? {});
1087
1087
  return result;
1088
1088
  }
1089
1089
  catch (err) {
1090
1090
  const msg = err.message || 'Query failed';
1091
+ if (isReadOnlyDbError(err)) {
1092
+ return {
1093
+ error: 'Write operations (CREATE, DELETE, SET, MERGE, REMOVE, DROP, ALTER, COPY, DETACH) are not allowed. The knowledge graph is read-only.',
1094
+ };
1095
+ }
1091
1096
  if (isWalCorruptionError(err)) {
1092
1097
  return {
1093
1098
  error: msg,
package/dist/mcp/tools.js CHANGED
@@ -155,6 +155,10 @@ TIPS:
155
155
  type: 'object',
156
156
  properties: {
157
157
  query: { type: 'string', description: 'Cypher query to execute' },
158
+ params: {
159
+ type: 'object',
160
+ description: 'Optional query parameters for placeholders (e.g. $name) to execute via prepared statement binding.',
161
+ },
158
162
  repo: {
159
163
  type: 'string',
160
164
  description: 'Repository name or path. Omit if only one repo is indexed.',
@@ -68,5 +68,8 @@ export declare const handleFileRequest: (req: {
68
68
  };
69
69
  json: (body: any) => void;
70
70
  }, repoPath: string) => Promise<void>;
71
+ export declare const handleQueryRequest: (req: express.Request, res: express.Response, resolveRepo: (repoName?: string) => Promise<{
72
+ storagePath: string;
73
+ } | undefined>) => Promise<void>;
71
74
  export declare const createServer: (port: number, host?: string) => Promise<void>;
72
75
  export {};
@@ -13,8 +13,8 @@ import path from 'path';
13
13
  import fs from 'fs/promises';
14
14
  import { createRequire } from 'node:module';
15
15
  import { loadMeta, listRegisteredRepos, getStoragePath } from '../storage/repo-manager.js';
16
- import { executeQuery, executePrepared, executeWithReusedStatement, streamQuery, flushWAL, closeLbug, withLbugDb, } from '../core/lbug/lbug-adapter.js';
17
- import { isWriteQuery } from '../core/lbug/pool-adapter.js';
16
+ import { executeQuery, executePrepared, executeWithReusedStatement, streamQuery, flushWAL, closeLbug, withLbugDb, isReadOnlyDbError, } from '../core/lbug/lbug-adapter.js';
17
+ import { isValidQueryParams } from '../core/lbug/query-params.js';
18
18
  import { NODE_TABLES } from '../_shared/index.js';
19
19
  import { searchFTSFromLbug } from '../core/search/bm25-index.js';
20
20
  import { hybridSearch } from '../core/search/hybrid-search.js';
@@ -533,6 +533,39 @@ export const handleFileRequest = async (req, res, repoPath) => {
533
533
  }
534
534
  }
535
535
  };
536
+ export const handleQueryRequest = async (req, res, resolveRepo) => {
537
+ try {
538
+ const cypher = req.body.cypher;
539
+ if (!cypher) {
540
+ res.status(400).json({ error: 'Missing "cypher" in request body' });
541
+ return;
542
+ }
543
+ const queryParams = req.body.params;
544
+ if (queryParams !== undefined && !isValidQueryParams(queryParams)) {
545
+ res.status(400).json({
546
+ error: '"params" must be a plain object with scalar values (string/number/boolean/null)',
547
+ });
548
+ return;
549
+ }
550
+ const entry = await resolveRepo(requestedRepo(req));
551
+ if (!entry) {
552
+ res.status(404).json({ error: 'Repository not found' });
553
+ return;
554
+ }
555
+ const lbugPath = path.join(entry.storagePath, 'lbug');
556
+ const result = await withLbugDb(lbugPath, () => executePrepared(cypher, queryParams ?? {}), {
557
+ readOnly: true,
558
+ });
559
+ res.json({ result });
560
+ }
561
+ catch (err) {
562
+ if (isReadOnlyDbError(err)) {
563
+ res.status(403).json({ error: 'Write queries are not allowed via the HTTP API' });
564
+ return;
565
+ }
566
+ res.status(500).json({ error: err.message || 'Query failed' });
567
+ }
568
+ };
536
569
  export const createServer = async (port, host = '127.0.0.1') => {
537
570
  const app = express();
538
571
  app.disable('x-powered-by');
@@ -892,28 +925,7 @@ export const createServer = async (port, host = '127.0.0.1') => {
892
925
  });
893
926
  // Execute Cypher query
894
927
  app.post('/api/query', async (req, res) => {
895
- try {
896
- const cypher = req.body.cypher;
897
- if (!cypher) {
898
- res.status(400).json({ error: 'Missing "cypher" in request body' });
899
- return;
900
- }
901
- if (isWriteQuery(cypher)) {
902
- res.status(403).json({ error: 'Write queries are not allowed via the HTTP API' });
903
- return;
904
- }
905
- const entry = await resolveRepo(requestedRepo(req));
906
- if (!entry) {
907
- res.status(404).json({ error: 'Repository not found' });
908
- return;
909
- }
910
- const lbugPath = path.join(entry.storagePath, 'lbug');
911
- const result = await withLbugDb(lbugPath, () => executeQuery(cypher));
912
- res.json({ result });
913
- }
914
- catch (err) {
915
- res.status(500).json({ error: err.message || 'Query failed' });
916
- }
928
+ await handleQueryRequest(req, res, resolveRepo);
917
929
  });
918
930
  // Search (supports mode: 'hybrid' | 'semantic' | 'bm25', and optional enrichment)
919
931
  app.post('/api/search', async (req, res) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitnexus",
3
- "version": "1.6.6-rc.7",
3
+ "version": "1.6.6-rc.8",
4
4
  "description": "Graph-powered code intelligence for AI agents. Index any codebase, query via MCP or CLI.",
5
5
  "author": "Abhigyan Patwari",
6
6
  "license": "PolyForm-Noncommercial-1.0.0",