Skip to content

Commit 5226436

Browse files
authored
ruvector 0.3.4: MCP security hardening, self-learning fixes, perf cleanups (#1296)
* fix(ruvector): MCP RCE/path-traversal/SSRF hardening and self-learning correctness (0.3.4) Security (all reproduced end to end over MCP stdio, now regression-tested): - Command injection via hooks_init.build_agents and hooks_diff_{analyze,classify}.commit: shell-string execSync replaced with argv execFileSync; focus enum and git-ref validation. Same fix for parallel-workers git blame/log, decompiler cargo fallback, git-churn, edge dashboard opener, and runAgenticFlow (no shell on POSIX). - validateRvfPath now confines to cwd (+ RUVECTOR_MCP_ROOTS) instead of a denylist that allowed ~/.ssh, ~/.npmrc etc. - Prototype pollution via "__proto__|x" pattern keys (repo-committed intelligence.json or hooks_import) and hooks_coedit_record / hooks_error_record keys. - decompile_url SSRF: private/link-local/loopback addresses refused, redirects re-checked, size cap. decompile_search queries are literal (ReDoS). - hooks_init and hooks_doctor --fix over MCP require RUVECTOR_MCP_ALLOW_INIT=1 since they rewrite .claude/settings.json permissions. - agentic-flow pinned to 3.0.0-alpha.2 instead of the floating @Alpha tag. Self-learning: - Cross-process lock on the intelligence store: concurrent hooks no longer lose updates (20/20 kept, was 7-11/20). - hooks learn and MCP trajectory/learn outcomes now reach hooks route; MCP learning state is persisted (intel.data.learning); MCP quality 0 no longer becomes 0.8. - Unseen agents use a neutral 0.5 prior so failing agents do not beat the default. - Double-Q acts on Q1+Q2; decision-transformer no longer rewards failures; recommendations are greedy; algorithm/rate config validated; non-finite rewards rejected. - save() merges engine patterns instead of replacing them; all commands share one store path. - pretrain writes routable, idempotent "state|agent" priors and no longer duplicates memories. - MCP server reloads the store when CLI hooks change it on disk. Packaging: build no longer copies stray .db/.rvf/.lock files into dist. Co-Authored-By: claude-flow <ruv@ruv.net> * perf(ruvector): no stray ruvector.db from hooks, fix MoE/EdgeFeatured wrappers, unref timer - IntelligenceEngine only builds a native VectorDb when storagePath is configured; otherwise recall uses in-memory cosine. The index was rebuilt every hook process anyway and the native default dropped ./ruvector.db (3.6-17 MB) into user projects. - MoEAttention / EdgeFeaturedAttention passed the wrong config shape to the native module and threw on construction. - TensorCompress auto-compress timer is unref'd so library users' processes exit. - `ruvector info` printed "Core Version: [object Object]". - verify-dist fails the publish if .db/.rvf/.lock files or .claude/ dirs would ship. - package.json description/keywords no longer advertise features with no code in this package (Graph RAG, hybrid search, ColBERT, TurboQuant, Mamba, "50+ attention"). Co-Authored-By: claude-flow <ruv@ruv.net>
1 parent fefc2ec commit 5226436

13 files changed

Lines changed: 806 additions & 163 deletions

‎npm/packages/ruvector/bin/cli.js‎

Lines changed: 260 additions & 58 deletions
Large diffs are not rendered by default.

‎npm/packages/ruvector/bin/mcp-server.js‎

Lines changed: 177 additions & 53 deletions
Large diffs are not rendered by default.

‎npm/packages/ruvector/package.json‎

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,18 @@
11
{
22
"name": "ruvector",
3-
"version": "0.3.3",
4-
"description": "Self-learning vector database for Node.js — hybrid search, Graph RAG, FlashAttention-3, HNSW, 50+ attention mechanisms",
3+
"version": "0.3.4",
4+
"description": "Self-learning vector database for Node.js — native HNSW, ONNX embeddings, GNN, attention, SONA routing, and an MCP server for agents",
55
"main": "dist/index.js",
66
"types": "dist/index.d.ts",
77
"bin": {
88
"ruvector": "bin/cli.js"
99
},
1010
"scripts": {
11-
"build": "tsc && node -e \"require('fs').cpSync('src/core/onnx','dist/core/onnx',{recursive:true})\"",
11+
"build": "tsc && node -e \"require('fs').cpSync('src/core/onnx','dist/core/onnx',{recursive:true,filter:(f)=>!/\\.(db|rvf|lock)$/.test(f)})\"",
1212
"verify-dist": "node scripts/verify-dist.js",
1313
"prepack": "npm run build && npm run verify-dist",
1414
"prepublishOnly": "npm run build && npm run verify-dist",
15-
"test": "node test/integration.js && node test/graph-modularity.js && node test/backend-fallback.js && node test/mincut-wasm.js && node test/metaharness-sdk.js && node test/metaharness-optional-deps.js && node test/cli-commands.js && node test/metaharness-cli.js && node test/db-workflow.js && node test/mcp-stdio.js && node test/metaharness-mcp.js && node test/sigterm-cleanup.js && node test/mcp-policy.js && node test/mcp-command-security.js && node test/mcp-handshake.js && node test/mcp-intel-durability.js && node test/startup-budget.js"
15+
"test": "node test/integration.js && node test/graph-modularity.js && node test/backend-fallback.js && node test/mincut-wasm.js && node test/metaharness-sdk.js && node test/metaharness-optional-deps.js && node test/cli-commands.js && node test/metaharness-cli.js && node test/db-workflow.js && node test/mcp-stdio.js && node test/metaharness-mcp.js && node test/sigterm-cleanup.js && node test/mcp-policy.js && node test/mcp-command-security.js && node test/mcp-exploit-regression.js && node test/mcp-handshake.js && node test/mcp-intel-durability.js && node test/startup-budget.js"
1616
},
1717
"keywords": [
1818
"vector",
@@ -48,12 +48,7 @@
4848
"shared-intelligence",
4949
"mcp",
5050
"edge-computing",
51-
"graph-rag",
5251
"hnsw",
53-
"hybrid-search",
54-
"colbert",
55-
"turboquant",
56-
"mamba",
5752
"pi-brain",
5853
"identity",
5954
"pi-key",

‎npm/packages/ruvector/scripts/verify-dist.js‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,4 +48,27 @@ if (missing.length > 0) {
4848
process.exit(1);
4949
}
5050

51+
// Local runtime artifacts (vector stores, RVF files, scan reports) must never
52+
// ride along in the tarball: `files` whitelists bin/ and dist/, so .npmignore
53+
// does not filter them.
54+
const STRAY = /\.(db|rvf|lock)$|(^|\/)\.claude(\/|$)/;
55+
const stray = [];
56+
for (const dir of ['bin', 'dist', 'wasm']) {
57+
const walk = (rel) => {
58+
const abs = path.join(pkgRoot, rel);
59+
if (!fs.existsSync(abs)) return;
60+
for (const ent of fs.readdirSync(abs, { withFileTypes: true })) {
61+
const child = path.posix.join(rel, ent.name);
62+
if (STRAY.test(child)) stray.push(child);
63+
else if (ent.isDirectory()) walk(child);
64+
}
65+
};
66+
walk(dir);
67+
}
68+
if (stray.length > 0) {
69+
console.error(`verify-dist: ${stray.length} stray runtime artifact(s) would be published:`);
70+
for (const rel of stray) console.error(` - ${rel}`);
71+
process.exit(1);
72+
}
73+
5174
console.log(`verify-dist: ${unique.length} dist path(s) present.`);

‎npm/packages/ruvector/src/core/attention-fallbacks.ts‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -364,9 +364,9 @@ export class MoEAttention {
364364
const attention = getAttentionModule();
365365
this.inner = new attention.MoEAttention({
366366
dim: config.dim,
367-
num_experts: config.numExperts,
368-
top_k: config.topK,
369-
expert_capacity: config.expertCapacity ?? 1.25,
367+
numExperts: config.numExperts,
368+
topK: config.topK,
369+
expertCapacity: config.expertCapacity ?? 1.25,
370370
});
371371
this.config = config;
372372
}
@@ -544,7 +544,7 @@ export class EdgeFeaturedAttention {
544544

545545
constructor(dim: number, edgeDim: number = 16) {
546546
const attention = getAttentionModule();
547-
this.inner = new attention.EdgeFeaturedAttention(dim, edgeDim);
547+
this.inner = new attention.EdgeFeaturedAttention({ nodeDim: dim, edgeDim, numHeads: 1 });
548548
this.dim = dim;
549549
this.edgeDim = edgeDim;
550550
}

‎npm/packages/ruvector/src/core/diff-embeddings.ts‎

Lines changed: 21 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,20 @@
88
* - Review prioritization
99
*/
1010

11-
import { execSync } from 'child_process';
11+
import { execFileSync } from 'child_process';
12+
13+
const GIT_OPTS = { encoding: 'utf8' as const, maxBuffer: 10 * 1024 * 1024, stdio: ['ignore', 'pipe', 'ignore'] as any };
14+
15+
/**
16+
* Validate a git revision supplied by a caller (CLI flag or MCP tool arg).
17+
* Revisions are passed as argv (never through a shell) and must not look like options.
18+
*/
19+
export function assertSafeGitRef(ref: string): string {
20+
if (typeof ref !== 'string' || !/^[0-9A-Za-z._\/~^@{}:-]{1,200}$/.test(ref) || ref.startsWith('-')) {
21+
throw new Error(`Invalid git revision: ${JSON.stringify(ref)}`);
22+
}
23+
return ref;
24+
}
1225
import { embed, embedBatch, isReady, initOnnxEmbedder } from './onnx-embedder';
1326

1427
export interface DiffHunk {
@@ -211,10 +224,7 @@ export async function analyzeFileDiff(file: string, diff: string, message: strin
211224
*/
212225
export function getCommitDiff(commitHash: string = 'HEAD'): string {
213226
try {
214-
return execSync(`git show ${commitHash} --format="" 2>/dev/null`, {
215-
encoding: 'utf8',
216-
maxBuffer: 10 * 1024 * 1024,
217-
});
227+
return execFileSync('git', ['show', '--format=', assertSafeGitRef(commitHash), '--'], GIT_OPTS) as string;
218228
} catch {
219229
return '';
220230
}
@@ -225,10 +235,7 @@ export function getCommitDiff(commitHash: string = 'HEAD'): string {
225235
*/
226236
export function getStagedDiff(): string {
227237
try {
228-
return execSync('git diff --cached 2>/dev/null', {
229-
encoding: 'utf8',
230-
maxBuffer: 10 * 1024 * 1024,
231-
});
238+
return execFileSync('git', ['diff', '--cached'], GIT_OPTS) as string;
232239
} catch {
233240
return '';
234241
}
@@ -239,10 +246,7 @@ export function getStagedDiff(): string {
239246
*/
240247
export function getUnstagedDiff(): string {
241248
try {
242-
return execSync('git diff 2>/dev/null', {
243-
encoding: 'utf8',
244-
maxBuffer: 10 * 1024 * 1024,
245-
});
249+
return execFileSync('git', ['diff'], GIT_OPTS) as string;
246250
} catch {
247251
return '';
248252
}
@@ -252,14 +256,13 @@ export function getUnstagedDiff(): string {
252256
* Analyze a commit
253257
*/
254258
export async function analyzeCommit(commitHash: string = 'HEAD'): Promise<CommitAnalysis> {
259+
assertSafeGitRef(commitHash);
255260
const diff = getCommitDiff(commitHash);
256261

257262
// Get commit metadata
258263
let message = '', author = '', date = '';
259264
try {
260-
const info = execSync(`git log -1 --format="%s|%an|%aI" ${commitHash} 2>/dev/null`, {
261-
encoding: 'utf8',
262-
}).trim();
265+
const info = (execFileSync('git', ['log', '-1', '--format=%s|%an|%aI', commitHash, '--'], GIT_OPTS) as string).trim();
263266
[message, author, date] = info.split('|');
264267
} catch {}
265268

@@ -327,9 +330,8 @@ export async function findSimilarCommits(
327330
// Get recent commits
328331
let commits: string[] = [];
329332
try {
330-
commits = execSync(`git log -${recentCommits} --format="%H" 2>/dev/null`, {
331-
encoding: 'utf8',
332-
}).trim().split('\n');
333+
const n = Math.max(1, Math.min(10000, Math.floor(Number(recentCommits)) || 50));
334+
commits = (execFileSync('git', ['log', `-${n}`, '--format=%H'], GIT_OPTS) as string).trim().split('\n');
333335
} catch {
334336
return [];
335337
}

‎npm/packages/ruvector/src/core/intelligence-engine.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
* Replaces the simple Q-learning approach with real ML-powered intelligence.
1212
*/
1313

14+
import * as path from 'path';
1415
import { FastAgentDB, Episode, Trajectory, EpisodeSearchResult } from './agentdb-fast';
1516
import { SonaEngine, SonaConfig, LearnedPattern, SonaStats, isSonaAvailable } from './sona-wrapper';
1617
import { OnnxEmbedder, OnnxEmbedderConfig, isOnnxAvailable, initOnnxEmbedder, getEmbedderProvenance, getEmbeddingSpaceIdentity, getEmbeddingSpaceId, embedBulk, shutdownParallelEmbedder } from './onnx-embedder';
@@ -312,11 +313,19 @@ export class IntelligenceEngine {
312313
}
313314

314315
private async initVectorDb(): Promise<void> {
316+
// Without an explicit storagePath the native VectorDb defaults to
317+
// ./ruvector.db, which dropped a multi-MB file into the user's project on
318+
// the first semantic hook. The index is rebuilt from the JSON store in every
319+
// (short-lived) hook process anyway, so brute-force cosine over memories is
320+
// both cheaper and file-free; an HNSW index is used only when a storage
321+
// location is configured.
322+
if (!this.config.storagePath) return;
315323
try {
316324
const VDB = getVectorDB();
317325
this.vectorDb = new VDB({
318326
dimensions: this.config.embeddingDim,
319327
distanceMetric: 'Cosine',
328+
storagePath: path.join(this.config.storagePath, 'engine-vectors.db'),
320329
});
321330
} catch {
322331
// VectorDB not available, use fallback

‎npm/packages/ruvector/src/core/learning-engine.ts‎

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,16 @@ export class LearningEngine {
148148
* Configure algorithm for a specific task type
149149
*/
150150
configure(task: TaskType, config: Partial<LearningConfig>): void {
151+
if (config.algorithm !== undefined &&
152+
!LearningEngine.getAlgorithms().some(a => a.algorithm === config.algorithm)) {
153+
throw new Error(`Unknown learning algorithm: ${config.algorithm}`);
154+
}
155+
for (const key of ['learningRate', 'discountFactor', 'epsilon'] as const) {
156+
const v = config[key];
157+
if (v !== undefined && !(Number.isFinite(v) && v >= 0 && v <= 1)) {
158+
throw new Error(`${key} must be a number in [0, 1], got ${v}`);
159+
}
160+
}
151161
const existing = this.configs.get(task) || TASK_ALGORITHM_MAP[task] || DEFAULT_TASK_CONFIG;
152162
this.configs.set(task, { ...existing, ...config });
153163
}
@@ -163,6 +173,9 @@ export class LearningEngine {
163173
* Update Q-value using the appropriate algorithm
164174
*/
165175
update(task: TaskType, experience: Experience): number {
176+
if (!Number.isFinite(experience.reward)) {
177+
throw new Error(`reward must be a finite number, got ${experience.reward}`);
178+
}
166179
const config = this.getConfig(task);
167180
let delta = 0;
168181

@@ -201,6 +214,8 @@ export class LearningEngine {
201214
case 'dqn':
202215
delta = this.dqnUpdate(experience, config);
203216
break;
217+
default:
218+
throw new Error(`Unknown learning algorithm: ${config.algorithm}`);
204219
}
205220

206221
// Update stats
@@ -212,23 +227,30 @@ export class LearningEngine {
212227
/**
213228
* Get best action for a state
214229
*/
215-
getBestAction(task: TaskType, state: string, actions: string[]): { action: string; confidence: number } {
230+
getBestAction(
231+
task: TaskType,
232+
state: string,
233+
actions: string[],
234+
options: { explore?: boolean } = {}
235+
): { action: string; confidence: number; explored?: boolean } {
216236
const config = this.getConfig(task);
217237

218-
// Epsilon-greedy exploration
219-
if (Math.random() < config.epsilon) {
238+
// Epsilon-greedy exploration (opt-in: recommendations shown to users are greedy)
239+
if (options.explore && Math.random() < config.epsilon) {
220240
const randomAction = actions[Math.floor(Math.random() * actions.length)];
221-
return { action: randomAction, confidence: 0.5 };
241+
return { action: randomAction, confidence: 0.5, explored: true };
222242
}
223243

224244
let bestAction = actions[0];
225245
let bestValue = -Infinity;
226246
let values: number[] = [];
227247

228248
const qTable = this.getQTable(state);
249+
// Double Q-learning writes to either table at random; act on their sum.
250+
const qTable2 = config.algorithm === 'double-q' ? this.getQTable2(state) : null;
229251

230252
for (const action of actions) {
231-
const value = qTable.get(action) || 0;
253+
const value = (qTable.get(action) || 0) + (qTable2 ? qTable2.get(action) || 0 : 0);
232254
values.push(value);
233255
if (value > bestValue) {
234256
bestValue = value;
@@ -501,7 +523,7 @@ export class LearningEngine {
501523

502524
// Weight by normalized return
503525
const normalizedReturn = returns[i] / (Math.abs(returns[0]) + 1);
504-
const target = currentQ + α * normalizedReturn * exp.reward;
526+
const target = currentQ + α * normalizedReturn;
505527
const delta = target - currentQ;
506528

507529
qTable.set(exp.action, target);

‎npm/packages/ruvector/src/core/parallel-workers.ts‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -486,11 +486,11 @@ export class ExtendedWorkerPool {
486486
}
487487
488488
async function gitBlame(files) {
489-
const { execSync } = require('child_process');
489+
const { execFileSync } = require('child_process');
490490
const results = [];
491491
for (const file of files) {
492492
try {
493-
const output = execSync(\`git blame --line-porcelain "\${file}" 2>/dev/null\`, { encoding: 'utf8', maxBuffer: 10 * 1024 * 1024 });
493+
const output = execFileSync('git', ['blame', '--line-porcelain', '--', String(file)], { encoding: 'utf8', maxBuffer: 10 * 1024 * 1024, stdio: ['ignore', 'pipe', 'ignore'] });
494494
const lines = [];
495495
let currentLine = {};
496496
for (const line of output.split('\\n')) {
@@ -511,13 +511,13 @@ export class ExtendedWorkerPool {
511511
}
512512
513513
async function gitChurn(files, since) {
514-
const { execSync } = require('child_process');
514+
const { execFileSync } = require('child_process');
515515
const results = [];
516-
const sinceArg = since ? \`--since="\${since}"\` : '--since="30 days ago"';
516+
const sinceArg = '--since=' + (since ? String(since) : '30 days ago');
517517
518518
for (const file of files) {
519519
try {
520-
const log = execSync(\`git log \${sinceArg} --format="%H|%an|%aI" --numstat -- "\${file}" 2>/dev/null\`, { encoding: 'utf8' });
520+
const log = execFileSync('git', ['log', sinceArg, '--format=%H|%an|%aI', '--numstat', '--', String(file)], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
521521
let additions = 0, deletions = 0, commits = 0;
522522
const authors = new Set();
523523
let lastModified = '';

‎npm/packages/ruvector/src/core/tensor-compress.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -455,6 +455,8 @@ export class TensorCompress {
455455
this.compressTimer = setInterval(() => {
456456
this.recompressAll();
457457
}, this.config.compressIntervalMs);
458+
// Background maintenance must not keep the host process alive.
459+
(this.compressTimer as any)?.unref?.();
458460
}
459461

460462
/**

0 commit comments

Comments
 (0)