magector 2.14.2 → 2.15.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
@@ -2,7 +2,7 @@
2
2
 
3
3
  **Technology-aware MCP server for Magento 2 and Adobe Commerce with intelligent indexing and search.**
4
4
 
5
- Magector is a Model Context Protocol (MCP) server that deeply understands Magento 2 and Adobe Commerce. It builds a semantic vector index of your entire codebase — 18,000+ files across hundreds of modules — and exposes 45 tools that let AI assistants search, navigate, and understand the code with domain-specific intelligence. Instead of grepping for keywords, your AI asks *"how are checkout totals calculated?"* and gets ranked, relevant results in under 50ms, enriched with Magento pattern detection (plugins, observers, controllers, DI preferences, layout XML, and 20+ more).
5
+ Magector is a Model Context Protocol (MCP) server that deeply understands Magento 2 and Adobe Commerce. It builds a semantic vector index of your entire codebase — 18,000+ files across hundreds of modules — and exposes 46 tools that let AI assistants search, navigate, and understand the code with domain-specific intelligence. Instead of grepping for keywords, your AI asks *"how are checkout totals calculated?"* and gets ranked, relevant results in under 50ms, enriched with Magento pattern detection (plugins, observers, controllers, DI preferences, layout XML, and 20+ more).
6
6
 
7
7
  [![Rust](https://img.shields.io/badge/rust-1.75+-orange.svg)](https://www.rust-lang.org)
8
8
  [![Node.js](https://img.shields.io/badge/node-22.5+-green.svg)](https://nodejs.org)
@@ -58,7 +58,7 @@ The result: your AI assistant calls one MCP tool and gets ranked, pattern-enrich
58
58
  - **Complexity analysis** -- cyclomatic complexity, function count, and hotspot detection across modules
59
59
  - **Fast** -- 10-45ms queries via persistent serve process, batched ONNX embedding with adaptive thread scaling
60
60
  - **LLM description enrichment** -- generate natural-language descriptions of di.xml files using Claude, stored in SQLite, and prepend them to embedding text so descriptions influence vector search ranking (not just post-retrieval display)
61
- - **MCP server** -- 45 tools integrating with Claude Code, Cursor, and any MCP-compatible AI tool
61
+ - **MCP server** -- 46 tools integrating with Claude Code, Cursor, and any MCP-compatible AI tool
62
62
  - **Clean architecture** -- Rust core handles all indexing/search, Node.js MCP server delegates to it
63
63
 
64
64
  ---
@@ -70,7 +70,7 @@ flowchart LR
70
70
  subgraph node ["Node.js Layer"]
71
71
  direction TB
72
72
  G["CLI<br/>init · index · search · describe"]
73
- E["MCP Server<br/>45 tools · LRU cache"]
73
+ E["MCP Server<br/>46 tools · LRU cache"]
74
74
  F["Persistent Serve Process"]
75
75
  G --> F
76
76
  E --> F
@@ -136,6 +136,37 @@ flowchart LR
136
136
 
137
137
  ---
138
138
 
139
+ ## Security
140
+
141
+ Magector operates on source code indexed from potentially-untrusted `vendor/` dependencies and is driven by an LLM that may be manipulated via prompt injection in indexed comments, docblocks, or markdown. The following hardening applies as of **v2.15.1**:
142
+
143
+ ### Path traversal protection
144
+
145
+ All tools that accept a `path` argument (`magento_read`, `magento_grep`, `magento_ast_search`, `magento_find_dataobject_issues`) route the input through `safePath()` / `safeRelPath()` helpers in `src/mcp-server.js`. These:
146
+
147
+ 1. Resolve the argument against `MAGENTO_ROOT` with `path.resolve()` (normalizes `..`, symlinks are not followed during validation).
148
+ 2. Reject any resolved path that does not lie inside `MAGENTO_ROOT`.
149
+
150
+ This prevents a hostile `vendor/` comment from instructing the LLM to e.g. `magento_read` `../../home/user/.ssh/id_rsa`. Both the standalone case handlers and their `magento_batch` counterparts share the same chokepoint.
151
+
152
+ ### Shell injection hardening in auto-update
153
+
154
+ `src/update.js` fetches the `latest` field from the npm registry and re-execs itself with the new version string. Previously this was interpolated into a shell command; a tampered registry response could inject shell metacharacters. As of v2.15.1:
155
+
156
+ - The re-exec passes argv as an **array** to a no-shell spawner (no intermediate shell).
157
+ - A semver-strict `isSafeVersion()` validator rejects any version string containing metacharacters or that does not match `X.Y.Z` / `X.Y.Z-prerelease` form.
158
+ - Fails closed: the auto-update is silently skipped rather than run a malformed version.
159
+
160
+ ### Unix socket permissions
161
+
162
+ The serve-proxy Unix socket at `.magector/serve.sock` is created with `chmod 0600` immediately after `listen()`. On multi-user systems, another local account can no longer connect and query the vector index (which would leak indexed source snippets). The chmod is best-effort on platforms that don't support it (logged to `.magector/magector.log`).
163
+
164
+ ### Reporting vulnerabilities
165
+
166
+ If you find a security issue, please open an issue on the GitHub repo and mark it as security-related. Do not post reproducers that leak actual source contents from private codebases.
167
+
168
+ ---
169
+
139
170
  ## Quick Start
140
171
 
141
172
  ### Prerequisites
@@ -371,7 +402,7 @@ npx magector index --force
371
402
 
372
403
  ## MCP Server Tools
373
404
 
374
- The MCP server exposes 45 tools for AI-assisted Magento 2 and Adobe Commerce development. All search tools return **structured JSON** with file paths, class names, methods, role badges, and content snippets -- enabling AI clients to parse results programmatically and minimize file-read round-trips.
405
+ The MCP server exposes 46 tools for AI-assisted Magento 2 and Adobe Commerce development. All search tools return **structured JSON** with file paths, class names, methods, role badges, and content snippets -- enabling AI clients to parse results programmatically and minimize file-read round-trips.
375
406
 
376
407
  ### Output Format
377
408
 
@@ -482,13 +513,14 @@ Auto-detects entry type from pattern (`/V1/...` → API, `snake_case` → event,
482
513
  | `magento_find_trigger` | Find database triggers across the codebase |
483
514
  | `magento_find_table_usage` | Find all PHP code referencing a specific database table |
484
515
 
485
- ### Null-Safety Analysis (v2.12–v2.13)
516
+ ### Null-Safety Analysis (v2.12–v2.15)
486
517
 
487
518
  | Tool | Description |
488
519
  |------|-------------|
489
520
  | `magento_ast_search` | Structural PHP code search using [semgrep](https://semgrep.dev). Understands PHP AST — matches by structure regardless of variable names, ignores comments/strings. Pattern syntax: `$X` = any expression, `$Y` = any identifier, `...` = any args. Example: `$X->getPayment()->$Y(...)`. Requires `semgrep`. **(v2.12)** |
490
521
  | `magento_enrich` | Build the method-chain enrichment index. Scans all `vendor/` PHP files for `->firstMethod()->secondMethod()` chains and detects null guards in surrounding code. Stores results in `.magector/enrichment.db` (SQLite, `node:sqlite`). Runs automatically after `magento_index`. **(v2.13)** |
491
522
  | `magento_find_null_risks` | Query the enrichment index for method chains without null guards. O(1) SQLite query instead of file scanning. Pass `firstMethod` to filter (e.g., `"getPayment"` → all `->getPayment()->anything()` without null guard). Requires `magento_enrich`. **(v2.13)** |
523
+ | `magento_find_dataobject_issues` | Detect `setX(null)` anti-pattern on Magento `DataObject` subclasses. `setX(null)` stores `['x' => null]` in `_data` — `hasX()` (via `array_key_exists`) returns `true` even when the value is `null`, creating false-positive guard conditions. Use during field-lifecycle audits or when debugging "value persists but shouldn't" bugs. Requires `semgrep`. **(v2.15)** |
492
524
 
493
525
  ### Search Enhancements (v2.1)
494
526
 
@@ -672,7 +704,7 @@ cd rust-core && cargo run --release -- validate -m ./magento2 --skip-index
672
704
  magector/
673
705
  ├── src/ # Node.js source
674
706
  │ ├── cli.js # CLI entry point (npx magector <command>)
675
- │ ├── mcp-server.js # MCP server (45 tools, structured JSON output)
707
+ │ ├── mcp-server.js # MCP server (46 tools, structured JSON output)
676
708
  │ ├── binary.js # Platform binary resolver
677
709
  │ ├── model.js # ONNX model resolver/downloader
678
710
  │ ├── init.js # Full init command (index + IDE config)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "magector",
3
- "version": "2.14.2",
3
+ "version": "2.15.1",
4
4
  "description": "Semantic code search for Magento 2 — index, search, MCP server",
5
5
  "type": "module",
6
6
  "main": "src/mcp-server.js",
@@ -33,10 +33,10 @@
33
33
  "ruvector": "^0.1.96"
34
34
  },
35
35
  "optionalDependencies": {
36
- "@magector/cli-darwin-arm64": "2.14.2",
37
- "@magector/cli-linux-x64": "2.14.2",
38
- "@magector/cli-linux-arm64": "2.14.2",
39
- "@magector/cli-win32-x64": "2.14.2"
36
+ "@magector/cli-darwin-arm64": "2.15.1",
37
+ "@magector/cli-linux-x64": "2.15.1",
38
+ "@magector/cli-linux-arm64": "2.15.1",
39
+ "@magector/cli-win32-x64": "2.15.1"
40
40
  },
41
41
  "keywords": [
42
42
  "magento",
package/src/mcp-server.js CHANGED
@@ -17,7 +17,7 @@ import {
17
17
  import { execFileSync, spawn } from 'child_process';
18
18
  import { createInterface } from 'readline';
19
19
  import { createServer as createNetServer, createConnection } from 'net';
20
- import { existsSync, statSync, unlinkSync, copyFileSync, renameSync, appendFileSync, writeFileSync, readFileSync, mkdirSync, openSync, closeSync, constants as fsConstants } from 'fs';
20
+ import { existsSync, statSync, unlinkSync, copyFileSync, renameSync, appendFileSync, writeFileSync, readFileSync, mkdirSync, openSync, closeSync, chmodSync, constants as fsConstants } from 'fs';
21
21
  import { stat } from 'fs/promises';
22
22
  import { glob } from 'glob';
23
23
  import path from 'path';
@@ -149,6 +149,38 @@ const SOCK_PATH = path.join(config.magentoRoot, '.magector', 'serve.sock');
149
149
  const FORMAT_CACHE_PATH = path.join(config.magentoRoot, '.magector', 'format-ok.json');
150
150
  const PRIMARY_LOCK_PATH = path.join(config.magentoRoot, '.magector', 'primary.lock');
151
151
 
152
+ // ─── Path Safety ────────────────────────────────────────────────
153
+ // All tool handlers accept user-supplied paths relative to MAGENTO_ROOT.
154
+ // Without validation, `../../../etc/passwd` would escape the project.
155
+ // A hostile indexed file could prompt-inject the LLM into requesting such a
156
+ // path and leak host files. These helpers are the single chokepoint.
157
+
158
+ /**
159
+ * Resolve a user-supplied path against a trusted root and verify it stays
160
+ * inside the root. Returns the absolute resolved path or null on escape.
161
+ */
162
+ function safePath(root, rel) {
163
+ if (rel === undefined || rel === null) return null;
164
+ const rootAbs = path.resolve(root);
165
+ const joined = path.resolve(rootAbs, String(rel));
166
+ if (joined !== rootAbs && !joined.startsWith(rootAbs + path.sep)) {
167
+ return null;
168
+ }
169
+ return joined;
170
+ }
171
+
172
+ /**
173
+ * Like safePath but returns the relative form (used for tools that invoke
174
+ * external processes with cwd=root and want a relative path argument).
175
+ * '.' means "the root itself".
176
+ */
177
+ function safeRelPath(root, rel) {
178
+ const abs = safePath(root, rel);
179
+ if (!abs) return null;
180
+ const r = path.relative(path.resolve(root), abs);
181
+ return r === '' ? '.' : r;
182
+ }
183
+
152
184
  /**
153
185
  * Expand brace patterns in include globs for GNU grep compatibility.
154
186
  * GNU grep --include does NOT support brace expansion (that's a shell feature).
@@ -737,7 +769,13 @@ function startSocketProxy() {
737
769
  logToFile('WARN', `Socket proxy error: ${err.message}`);
738
770
  });
739
771
  socketServer.listen(SOCK_PATH, () => {
740
- logToFile('INFO', `Socket proxy listening on ${SOCK_PATH}`);
772
+ // Restrict socket to the owning user — without this, on multi-user
773
+ // systems any local account could connect to the serve proxy and query
774
+ // the index (leaking indexed code snippets to other local users).
775
+ try { chmodSync(SOCK_PATH, 0o600); } catch (err) {
776
+ logToFile('WARN', `Failed to chmod socket to 0600: ${err.message}`);
777
+ }
778
+ logToFile('INFO', `Socket proxy listening on ${SOCK_PATH} (mode 0600)`);
741
779
  });
742
780
  }
743
781
 
@@ -3663,7 +3701,11 @@ async function astSearch(pattern, searchPath, lang, maxResults) {
3663
3701
  const root = config.magentoRoot;
3664
3702
  if (!root) throw new Error('MAGENTO_ROOT not set');
3665
3703
 
3666
- const targetPath = searchPath ? path.join(root, searchPath) : root;
3704
+ const targetPath = searchPath ? safePath(root, searchPath) : path.resolve(root);
3705
+ if (!targetPath) {
3706
+ logToFile('WARN', `ast_search: rejected path traversal attempt: "${searchPath}"`);
3707
+ throw new Error(`Path escapes project root: ${searchPath}`);
3708
+ }
3667
3709
  const semgrepLang = lang || 'php';
3668
3710
  const limit = Math.min(maxResults || 50, 200);
3669
3711
 
@@ -3728,12 +3770,34 @@ async function astSearch(pattern, searchPath, lang, maxResults) {
3728
3770
  if (parsed.errors && parsed.errors.length > 0) {
3729
3771
  logToFile('WARN', `ast_search: semgrep reported ${parsed.errors.length} error(s): ${parsed.errors.slice(0, 3).map(e => e.message || e.type || JSON.stringify(e)).join('; ')}`);
3730
3772
  }
3731
- return findings.map(r => ({
3732
- file: r.path.replace(root + '/', ''),
3733
- line: r.start.line,
3734
- endLine: r.end.line,
3735
- snippet: (r.extra?.lines || '').trim()
3736
- }));
3773
+ return findings.map(r => {
3774
+ // semgrep >=1.100 may return "requires login" in r.extra.lines for unlicensed installs.
3775
+ // Fall back to r.extra.message which contains the matched expression (always available).
3776
+ const rawLines = r.extra?.lines || '';
3777
+ const snippet = (rawLines && rawLines !== 'requires login')
3778
+ ? rawLines.trim()
3779
+ : (r.extra?.message || '').trim();
3780
+ return {
3781
+ file: r.path.replace(root + '/', ''),
3782
+ line: r.start.line,
3783
+ endLine: r.end.line,
3784
+ snippet
3785
+ };
3786
+ });
3787
+ }
3788
+
3789
+ // ─── DataObject set-null Anti-pattern Detection ─────────────────
3790
+
3791
+ async function findDataObjectIssues(searchPath, maxResults) {
3792
+ // Detects DataObject::setX(null) anti-pattern:
3793
+ // setX(null) stores ['x' => null] in _data — key EXISTS with null value.
3794
+ // hasX() / hasData('x') calls array_key_exists() → returns true even for null.
3795
+ // This causes false-positive guard conditions: hasX() passes, getX() returns null.
3796
+ // Correct way to fully clear: unsetData('x') removes the key entirely.
3797
+ const allResults = await astSearch('$X->$SETTER(null)', searchPath, 'php', 500);
3798
+ const setterNullRegex = /->set[A-Z]\w+\s*\(\s*null\s*\)/;
3799
+ const limit = Math.min(maxResults || 100, 500);
3800
+ return allResults.filter(r => setterNullRegex.test(r.snippet)).slice(0, limit);
3737
3801
  }
3738
3802
 
3739
3803
  // ─── MCP Server ─────────────────────────────────────────────────
@@ -4494,6 +4558,24 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
4494
4558
  required: ['pattern']
4495
4559
  }
4496
4560
  },
4561
+ {
4562
+ name: 'magento_find_dataobject_issues',
4563
+ description: 'Detect DataObject::setX(null) anti-pattern calls. In Magento, classes extending DataObject store values in a _data array. Calling setX(null) stores the key with a null value — so hasX()/hasData(\'x\') (which use array_key_exists) return true even though the value is null. Downstream guard conditions silently pass, but getX() returns null. The correct way to clear is unsetData(\'x\'). Use this during field-lifecycle audits or when debugging "value persists but shouldn\'t" bugs. ⚡ For multi-query workflows use magento_batch.',
4564
+ inputSchema: {
4565
+ type: 'object',
4566
+ properties: {
4567
+ path: {
4568
+ type: 'string',
4569
+ description: 'Subdirectory to search (relative to MAGENTO_ROOT). Default: entire codebase. Example: "vendor/acme/"'
4570
+ },
4571
+ maxResults: {
4572
+ type: 'number',
4573
+ description: 'Maximum matches to return (default: 100, max: 500)',
4574
+ default: 100
4575
+ }
4576
+ }
4577
+ }
4578
+ },
4497
4579
  {
4498
4580
  name: 'magento_enrich',
4499
4581
  description: 'Build the method-chain enrichment index. Scans all vendor/ PHP files for two-step method chains (->firstMethod()->secondMethod()) and analyses whether each call has a null guard in surrounding code. Results stored in .magector/enrichment.db. Run this once after magento_index, then use magento_find_null_risks for instant O(1) null-safety queries instead of 20+ grep calls. Also runs automatically after magento_index completes.',
@@ -4583,7 +4665,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
4583
4665
  // These tools have filesystem/di.xml fallbacks — work without serve process
4584
4666
  'magento_find_class', 'magento_find_method', 'magento_find_plugin',
4585
4667
  'magento_find_observer', 'magento_find_di_wiring', 'magento_module_structure',
4586
- 'magento_batch', 'magento_find_config', 'magento_find_callers', 'magento_grep', 'magento_read', 'magento_trace_api', 'magento_ast_search', 'magento_find_null_risks'];
4668
+ 'magento_batch', 'magento_find_config', 'magento_find_callers', 'magento_grep', 'magento_read', 'magento_trace_api', 'magento_ast_search', 'magento_find_null_risks', 'magento_find_dataobject_issues'];
4587
4669
  if (warmupInProgress && !indexFreeTools.includes(name)) {
4588
4670
  logToFile('REQ', `${name} → blocked (warmup: loading index)`);
4589
4671
  return {
@@ -6426,7 +6508,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
6426
6508
  break;
6427
6509
  }
6428
6510
  case 'magento_read': {
6429
- const filePath = path.join(config.magentoRoot, a.path);
6511
+ const filePath = safePath(config.magentoRoot, a.path);
6512
+ if (!filePath) {
6513
+ logToFile('WARN', `batch read: rejected path traversal attempt: "${a.path}"`);
6514
+ text = `Path escapes project root: ${a.path}`;
6515
+ break;
6516
+ }
6430
6517
  let fileContent;
6431
6518
  try { fileContent = readFileSync(filePath, 'utf-8'); } catch {
6432
6519
  text = `File not found: ${a.path}`;
@@ -6451,7 +6538,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
6451
6538
  break;
6452
6539
  }
6453
6540
  case 'magento_grep': {
6454
- const searchPath = a.path || '.';
6541
+ const searchPath = safeRelPath(config.magentoRoot, a.path || '.');
6542
+ if (!searchPath) {
6543
+ logToFile('WARN', `batch grep: rejected path traversal attempt: "${a.path}"`);
6544
+ text = `Path escapes project root: ${a.path}`;
6545
+ break;
6546
+ }
6455
6547
  const include = a.include || '*.php';
6456
6548
  const maxRes = Math.min(a.maxResults || 30, 100);
6457
6549
  const batchCtx = a.context !== undefined ? a.context : 4;
@@ -6485,6 +6577,16 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
6485
6577
  }
6486
6578
  break;
6487
6579
  }
6580
+ case 'magento_find_dataobject_issues': {
6581
+ const doResults = await findDataObjectIssues(a.path, a.maxResults);
6582
+ if (doResults.length === 0) {
6583
+ text = 'No DataObject setX(null) anti-pattern calls found.';
6584
+ } else {
6585
+ text = `Found ${doResults.length} setX(null) call(s) — review for DataObject anti-pattern:\n\n`;
6586
+ for (const r of doResults) text += `${r.file}:${r.line}: ${r.snippet}\n`;
6587
+ }
6588
+ break;
6589
+ }
6488
6590
  case 'magento_find_null_risks': {
6489
6591
  const bRoot = config.magentoRoot;
6490
6592
  const bLimit = Math.min(a.limit || 100, 500);
@@ -6519,7 +6621,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
6519
6621
  case 'magento_grep': {
6520
6622
  const root = config.magentoRoot;
6521
6623
  if (!root) return { content: [{ type: 'text', text: 'MAGENTO_ROOT not set.' }], isError: true };
6522
- const searchPath = args.path || '.';
6624
+ const searchPath = safeRelPath(root, args.path || '.');
6625
+ if (!searchPath) {
6626
+ logToFile('WARN', `grep: rejected path traversal attempt: "${args.path}"`);
6627
+ return { content: [{ type: 'text', text: `Path escapes project root: ${args.path}` }], isError: true };
6628
+ }
6523
6629
  const include = args.include || '*.php';
6524
6630
  const maxResults = Math.min(args.maxResults || 50, 200);
6525
6631
  const ctxLines = args.context !== undefined ? args.context : 4;
@@ -6706,7 +6812,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
6706
6812
  case 'magento_read': {
6707
6813
  const root = config.magentoRoot;
6708
6814
  if (!root) return { content: [{ type: 'text', text: 'MAGENTO_ROOT not set.' }], isError: true };
6709
- const filePath = path.join(root, args.path);
6815
+ const filePath = safePath(root, args.path);
6816
+ if (!filePath) {
6817
+ logToFile('WARN', `read: rejected path traversal attempt: "${args.path}"`);
6818
+ return { content: [{ type: 'text', text: `Path escapes project root: ${args.path}` }], isError: true };
6819
+ }
6710
6820
  let content;
6711
6821
  try { content = readFileSync(filePath, 'utf-8'); } catch (err) {
6712
6822
  logToFile('WARN', `read: file not found: ${args.path} (${err.code || err.message})`);
@@ -6762,6 +6872,23 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
6762
6872
  return { content: [{ type: 'text', text }] };
6763
6873
  }
6764
6874
 
6875
+ case 'magento_find_dataobject_issues': {
6876
+ const doResults = await findDataObjectIssues(args.path, args.maxResults);
6877
+ if (doResults.length === 0) {
6878
+ return { content: [{ type: 'text', text: '## magento_find_dataobject_issues\n\nNo `setX(null)` calls found.' }] };
6879
+ }
6880
+ let doText = `## magento_find_dataobject_issues\n`;
6881
+ doText += `Found **${doResults.length}** \`setX(null)\` call(s)\n\n`;
6882
+ doText += `> ⚠️ **DataObject anti-pattern**: \`setX(null)\` stores \`['x' => null]\` in \`_data\` — \`hasX()\` returns \`true\` via \`array_key_exists\` even for \`null\`. `;
6883
+ doText += `Downstream \`hasX()\` guards silently pass while \`getX()\` returns \`null\`. `;
6884
+ doText += `**Fix**: use \`unsetData('x')\` to remove the key entirely.\n\n`;
6885
+ for (const r of doResults) {
6886
+ const lineInfo = r.endLine && r.endLine !== r.line ? `${r.line}-${r.endLine}` : String(r.line);
6887
+ doText += `**${r.file}:${lineInfo}**\n\`\`\`php\n${r.snippet}\n\`\`\`\n\n`;
6888
+ }
6889
+ return { content: [{ type: 'text', text: doText }] };
6890
+ }
6891
+
6765
6892
  case 'magento_enrich': {
6766
6893
  const root = config.magentoRoot;
6767
6894
  if (!root) return { content: [{ type: 'text', text: 'MAGENTO_ROOT not set.' }], isError: true };
package/src/update.js CHANGED
@@ -8,7 +8,7 @@
8
8
  * Never blocks the CLI on failure — network errors are silently ignored.
9
9
  */
10
10
  import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
11
- import { execSync } from 'child_process';
11
+ import { execFileSync } from 'child_process';
12
12
  import { homedir } from 'os';
13
13
  import path from 'path';
14
14
  import { fileURLToPath } from 'url';
@@ -140,14 +140,29 @@ export async function checkForUpdate(command, originalArgs) {
140
140
  }
141
141
  }
142
142
 
143
+ /**
144
+ * Validate a semver string to prevent shell injection via malicious registry
145
+ * responses. Only digits, dots, dashes and alphanumerics allowed (semver prerelease).
146
+ * Example: "1.2.3", "1.2.3-beta.1", "2.0.0-rc.9" — yes. "1; rm -rf ~" — no.
147
+ */
148
+ function isSafeVersion(v) {
149
+ return typeof v === 'string' && /^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$/.test(v);
150
+ }
151
+
143
152
  /**
144
153
  * Re-exec the current command with the latest version.
145
154
  */
146
155
  function reExec(current, latest, originalArgs) {
156
+ // Defensive: reject anything that doesn't look like a real semver so a
157
+ // compromised npm registry response can't inject shell metacharacters.
158
+ if (!isSafeVersion(latest)) {
159
+ return; // silently skip — never block CLI on update check
160
+ }
147
161
  console.log(`\n⬆ Updating magector: v${current} → v${latest}...\n`);
148
162
  try {
149
- const cmd = `npx -y magector@${latest} ${originalArgs.join(' ')}`;
150
- execSync(cmd, {
163
+ // execFileSync with an argv array (no shell) — originalArgs are passed as
164
+ // individual argv entries, so spaces/metachars in them can't expand.
165
+ execFileSync('npx', ['-y', `magector@${latest}`, ...originalArgs], {
151
166
  stdio: 'inherit',
152
167
  env: { ...process.env, MAGECTOR_NO_UPDATE: '1' }
153
168
  });