@cdxgen/cdxgen 13.5.0 → 13.6.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 (182) hide show
  1. package/README.md +6 -6
  2. package/bin/cdxgen.js +4 -0
  3. package/bin/evinse.js +3 -0
  4. package/bin/validate.js +6 -0
  5. package/data/license-aliases.json +24 -2
  6. package/data/remediations.json +124 -0
  7. package/data/rules/ai-bom.yaml +1 -1
  8. package/data/rules/brotli-decoder.yaml +107 -0
  9. package/data/rules/vscode-extensions.yaml +75 -0
  10. package/data/spdx-license-list.json +121 -7
  11. package/data/spdx-licenses.json +69 -0
  12. package/data/spdx.schema.json +845 -2
  13. package/lib/cli/bomAssembly.js +92 -3
  14. package/lib/cli/index.js +108 -1
  15. package/lib/cli/jsBom.js +153 -64
  16. package/lib/cli/jvmBom.js +237 -46
  17. package/lib/cli/managedBom.js +142 -16
  18. package/lib/cli/nativeBom.js +42 -5
  19. package/lib/core/boundedFile.js +344 -0
  20. package/lib/core/brotliProbe.js +182 -0
  21. package/lib/core/env.js +8 -0
  22. package/lib/core/fs.js +12 -0
  23. package/lib/core/httpClient.js +98 -1
  24. package/lib/core/lockPaths.js +38 -0
  25. package/lib/core/paths.js +24 -1
  26. package/lib/core/processTree.js +5 -0
  27. package/lib/core/toolRegistry.js +170 -0
  28. package/lib/ecosystems/cppEvidence.js +263 -106
  29. package/lib/ecosystems/cppIncludeResolver.js +16 -5
  30. package/lib/ecosystems/ecosystems.js +25 -10
  31. package/lib/ecosystems/gemHostProbe.js +301 -0
  32. package/lib/ecosystems/gradleutils.js +828 -224
  33. package/lib/ecosystems/parsers-bazel.js +107 -6
  34. package/lib/ecosystems/parsers-js.js +505 -22
  35. package/lib/ecosystems/parsers-misc.js +209 -20
  36. package/lib/ecosystems/parsers-python.js +89 -2
  37. package/lib/ecosystems/pythonutils.js +128 -2
  38. package/lib/ecosystems/rubyutils.js +538 -31
  39. package/lib/ecosystems/scalacliutils.js +2 -0
  40. package/lib/ecosystems/scalasem.js +796 -151
  41. package/lib/ecosystems/vendoredCode.js +237 -3
  42. package/lib/ecosystems/venvProbe.js +360 -0
  43. package/lib/evinser/evinser.js +346 -43
  44. package/lib/helpers/agenticToolutils.js +413 -93
  45. package/lib/helpers/vsixutils.js +1574 -315
  46. package/lib/inventory/atomUtils.js +77 -0
  47. package/lib/inventory/brotliDecoder.js +142 -0
  48. package/lib/inventory/buildToolRateLimit.js +1 -1
  49. package/lib/inventory/cbomutils.js +56 -7
  50. package/lib/inventory/deps.js +35 -0
  51. package/lib/inventory/dosai.js +139 -5
  52. package/lib/inventory/dotnetRuntime.js +217 -0
  53. package/lib/inventory/envcontext.js +307 -13
  54. package/lib/inventory/evidenceUtils.js +11 -0
  55. package/lib/inventory/fetchBatch.js +1 -1
  56. package/lib/inventory/formulationParsers.js +31 -3
  57. package/lib/inventory/gtfobins.js +13 -0
  58. package/lib/inventory/hostRuntimeProbe.js +686 -0
  59. package/lib/inventory/jvmLocalRepos.js +9 -2
  60. package/lib/inventory/jvmToolEnv.js +29 -0
  61. package/lib/inventory/licenseEnhancer.js +12 -0
  62. package/lib/inventory/mavenCentralMirror.js +534 -0
  63. package/lib/inventory/nodeBuildInfo.js +218 -0
  64. package/lib/inventory/nodeHostProbe.js +202 -0
  65. package/lib/inventory/osPackageResolver.js +182 -0
  66. package/lib/inventory/phpBuildInfo.js +10 -59
  67. package/lib/inventory/phpHostProbe.js +32 -488
  68. package/lib/inventory/rubyBuildInfo.js +972 -0
  69. package/lib/inventory/rubyHostProbe.js +218 -0
  70. package/lib/inventory/scalaCoords.js +4 -3
  71. package/lib/inventory/scalaDedupe.js +440 -0
  72. package/lib/inventory/source.js +49 -2
  73. package/lib/managers/binary.js +337 -3
  74. package/lib/managers/docker.js +79 -0
  75. package/lib/parsers/largeJson.js +28 -11
  76. package/lib/parsers/vscodeExtensionId.js +183 -0
  77. package/lib/server/server.js +12 -1
  78. package/lib/stages/postgen/ruleEngine.js +13 -5
  79. package/lib/stages/pregen/envAudit.js +1 -0
  80. package/lib/stages/pregen/pregen.js +114 -57
  81. package/lib/validator/bomValidator.js +6 -1
  82. package/lib/validator/generated/validate-1.6.mjs +1 -1
  83. package/lib/validator/generated/validate-1.7.mjs +1 -1
  84. package/lib/validator/generated/validate-2.0.mjs +1 -1
  85. package/lib/validator/index.js +1 -1
  86. package/package.json +3 -2
  87. package/types/lib/cli/bomAssembly.d.ts +13 -1
  88. package/types/lib/cli/bomAssembly.d.ts.map +1 -1
  89. package/types/lib/cli/index.d.ts.map +1 -1
  90. package/types/lib/cli/jsBom.d.ts +1 -17
  91. package/types/lib/cli/jsBom.d.ts.map +1 -1
  92. package/types/lib/cli/jvmBom.d.ts.map +1 -1
  93. package/types/lib/cli/managedBom.d.ts.map +1 -1
  94. package/types/lib/cli/nativeBom.d.ts.map +1 -1
  95. package/types/lib/core/boundedFile.d.ts +70 -0
  96. package/types/lib/core/boundedFile.d.ts.map +1 -0
  97. package/types/lib/core/brotliProbe.d.ts +37 -0
  98. package/types/lib/core/brotliProbe.d.ts.map +1 -0
  99. package/types/lib/core/env.d.ts +4 -0
  100. package/types/lib/core/env.d.ts.map +1 -1
  101. package/types/lib/core/fs.d.ts.map +1 -1
  102. package/types/lib/core/httpClient.d.ts +24 -0
  103. package/types/lib/core/httpClient.d.ts.map +1 -1
  104. package/types/lib/core/lockPaths.d.ts +19 -0
  105. package/types/lib/core/lockPaths.d.ts.map +1 -0
  106. package/types/lib/core/processTree.d.ts +2 -0
  107. package/types/lib/core/processTree.d.ts.map +1 -1
  108. package/types/lib/core/toolRegistry.d.ts +36 -0
  109. package/types/lib/core/toolRegistry.d.ts.map +1 -0
  110. package/types/lib/ecosystems/cppEvidence.d.ts +40 -13
  111. package/types/lib/ecosystems/cppEvidence.d.ts.map +1 -1
  112. package/types/lib/ecosystems/cppIncludeResolver.d.ts +2 -1
  113. package/types/lib/ecosystems/cppIncludeResolver.d.ts.map +1 -1
  114. package/types/lib/ecosystems/ecosystems.d.ts.map +1 -1
  115. package/types/lib/ecosystems/gemHostProbe.d.ts +28 -0
  116. package/types/lib/ecosystems/gemHostProbe.d.ts.map +1 -0
  117. package/types/lib/ecosystems/gradleutils.d.ts +47 -13
  118. package/types/lib/ecosystems/gradleutils.d.ts.map +1 -1
  119. package/types/lib/ecosystems/parsers-bazel.d.ts.map +1 -1
  120. package/types/lib/ecosystems/parsers-js.d.ts +17 -20
  121. package/types/lib/ecosystems/parsers-js.d.ts.map +1 -1
  122. package/types/lib/ecosystems/parsers-misc.d.ts +19 -0
  123. package/types/lib/ecosystems/parsers-misc.d.ts.map +1 -1
  124. package/types/lib/ecosystems/parsers-python.d.ts +10 -0
  125. package/types/lib/ecosystems/parsers-python.d.ts.map +1 -1
  126. package/types/lib/ecosystems/pythonutils.d.ts +20 -2
  127. package/types/lib/ecosystems/pythonutils.d.ts.map +1 -1
  128. package/types/lib/ecosystems/rubyutils.d.ts +144 -12
  129. package/types/lib/ecosystems/rubyutils.d.ts.map +1 -1
  130. package/types/lib/ecosystems/scalacliutils.d.ts.map +1 -1
  131. package/types/lib/ecosystems/vendoredCode.d.ts +26 -0
  132. package/types/lib/ecosystems/vendoredCode.d.ts.map +1 -1
  133. package/types/lib/ecosystems/venvProbe.d.ts +40 -0
  134. package/types/lib/ecosystems/venvProbe.d.ts.map +1 -0
  135. package/types/lib/evinser/evinser.d.ts +63 -8
  136. package/types/lib/evinser/evinser.d.ts.map +1 -1
  137. package/types/lib/inventory/atomUtils.d.ts +19 -0
  138. package/types/lib/inventory/atomUtils.d.ts.map +1 -1
  139. package/types/lib/inventory/brotliDecoder.d.ts +58 -0
  140. package/types/lib/inventory/brotliDecoder.d.ts.map +1 -0
  141. package/types/lib/inventory/cbomutils.d.ts +17 -0
  142. package/types/lib/inventory/cbomutils.d.ts.map +1 -1
  143. package/types/lib/inventory/deps.d.ts.map +1 -1
  144. package/types/lib/inventory/dosai.d.ts +17 -1
  145. package/types/lib/inventory/dosai.d.ts.map +1 -1
  146. package/types/lib/inventory/dotnetRuntime.d.ts +44 -0
  147. package/types/lib/inventory/dotnetRuntime.d.ts.map +1 -0
  148. package/types/lib/inventory/envcontext.d.ts +52 -6
  149. package/types/lib/inventory/envcontext.d.ts.map +1 -1
  150. package/types/lib/inventory/evidenceUtils.d.ts.map +1 -1
  151. package/types/lib/inventory/formulationParsers.d.ts.map +1 -1
  152. package/types/lib/inventory/gtfobins.d.ts.map +1 -1
  153. package/types/lib/inventory/hostRuntimeProbe.d.ts +44 -0
  154. package/types/lib/inventory/hostRuntimeProbe.d.ts.map +1 -0
  155. package/types/lib/inventory/jvmToolEnv.d.ts +12 -0
  156. package/types/lib/inventory/jvmToolEnv.d.ts.map +1 -1
  157. package/types/lib/inventory/licenseEnhancer.d.ts.map +1 -1
  158. package/types/lib/inventory/mavenCentralMirror.d.ts +170 -0
  159. package/types/lib/inventory/mavenCentralMirror.d.ts.map +1 -0
  160. package/types/lib/inventory/nodeBuildInfo.d.ts +95 -0
  161. package/types/lib/inventory/nodeBuildInfo.d.ts.map +1 -0
  162. package/types/lib/inventory/nodeHostProbe.d.ts +15 -0
  163. package/types/lib/inventory/nodeHostProbe.d.ts.map +1 -0
  164. package/types/lib/inventory/osPackageResolver.d.ts +38 -0
  165. package/types/lib/inventory/osPackageResolver.d.ts.map +1 -1
  166. package/types/lib/inventory/phpBuildInfo.d.ts.map +1 -1
  167. package/types/lib/inventory/phpHostProbe.d.ts.map +1 -1
  168. package/types/lib/inventory/rubyBuildInfo.d.ts +271 -0
  169. package/types/lib/inventory/rubyBuildInfo.d.ts.map +1 -0
  170. package/types/lib/inventory/rubyHostProbe.d.ts +47 -0
  171. package/types/lib/inventory/rubyHostProbe.d.ts.map +1 -0
  172. package/types/lib/inventory/source.d.ts +10 -0
  173. package/types/lib/inventory/source.d.ts.map +1 -1
  174. package/types/lib/managers/binary.d.ts.map +1 -1
  175. package/types/lib/managers/docker.d.ts +12 -0
  176. package/types/lib/managers/docker.d.ts.map +1 -1
  177. package/types/lib/parsers/largeJson.d.ts +4 -3
  178. package/types/lib/parsers/largeJson.d.ts.map +1 -1
  179. package/types/lib/server/server.d.ts.map +1 -1
  180. package/types/lib/stages/pregen/envAudit.d.ts.map +1 -1
  181. package/types/lib/stages/pregen/pregen.d.ts +12 -8
  182. package/types/lib/stages/pregen/pregen.d.ts.map +1 -1
@@ -1,14 +1,18 @@
1
- import { readdirSync, readFileSync } from "node:fs";
1
+ import { readdirSync, realpathSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
- import { join } from "node:path";
3
+ import { basename, dirname, isAbsolute, join } from "node:path";
4
4
 
5
5
  import { build } from "@cdxgen/cdx-purl";
6
6
  import { parse as loadYaml } from "yaml";
7
7
 
8
8
  import { DEBUG_MODE, readEnvironmentVariable } from "../core/activity.js";
9
+ import { readUserFileText } from "../core/boundedFile.js";
9
10
  import { safeExistsSync } from "../core/fs.js";
10
- import { isMac, isWin } from "../core/paths.js";
11
+ import { isMac, isWin, redactHomePath } from "../core/paths.js";
11
12
  import { parseToml } from "../core/toml.js";
13
+ import { findIdeProductInfo, IDE_PRODUCTS } from "./vsixutils.js";
14
+
15
+ export { redactHomePath };
12
16
 
13
17
  /**
14
18
  * The purl type used for installed agentic CLI tools. These tools ship as
@@ -45,29 +49,6 @@ function buildGenericPurl(name, version) {
45
49
  }
46
50
  }
47
51
 
48
- /**
49
- * Rewrite an absolute path under the user's home directory to a `~`-relative
50
- * form so the emitted BOM does not carry the local username.
51
- *
52
- * @param {string} filePath Absolute path
53
- * @returns {string} Home-relative path when applicable, else the input
54
- */
55
- export function redactHomePath(filePath) {
56
- const home = homedir();
57
- const value = String(filePath || "");
58
- if (!home || !value.startsWith(home)) {
59
- return value;
60
- }
61
- // Require a path boundary so a sibling home such as /Users/alice-backup is
62
- // not rewritten as if it sat under /Users/alice.
63
- const remainder = value.slice(home.length);
64
- if (remainder && !/^[/\\]/u.test(remainder)) {
65
- return value;
66
- }
67
- const rest = remainder.replace(/^[/\\]/u, "");
68
- return rest ? `~/${rest.replaceAll("\\", "/")}` : "~";
69
- }
70
-
71
52
  // Directory or file names inside a tool home that must never be read for their
72
53
  // values. They hold credentials, transcripts, or telemetry. The scanner may
73
54
  // count entries under some of these, but it does not open their contents.
@@ -95,23 +76,39 @@ const SENSITIVE_ENTRY_NAMES = new Set([
95
76
  * extension locations). Only the current platform's directories plus
96
77
  * always-present dot-directories are included.
97
78
  *
98
- * @returns {Array<{tool: string, name: string, dirs: string[], supportDirs?: string[]}>}
79
+ * An entry may also name `mcpConfigs`, the files and keys where the tool
80
+ * lists its MCP servers, and `ide`, the IDE_PRODUCTS id of the editor the tool
81
+ * ships in, whose installed version and extensions folder it then reports.
82
+ *
83
+ * @param {string} [home] Home directory to resolve the folders against
84
+ * @returns {Array<{tool: string, name: string, dirs: string[], supportDirs?: string[], mcpConfigs?: Array<{file: string, key: string}>, ide?: string}>}
99
85
  * One entry per known agentic CLI tool.
100
86
  */
101
- export function getAgenticToolDirs() {
102
- const home = homedir();
87
+ export function getAgenticToolDirs(home = homedir()) {
88
+ // These variables describe the current user, so another home, such as a
89
+ // fixture or another user's home, uses the defaults.
90
+ const ownHome = home === homedir();
103
91
  const appData =
104
- readEnvironmentVariable("APPDATA") || join(home, "AppData", "Roaming");
92
+ (ownHome && readEnvironmentVariable("APPDATA")) ||
93
+ join(home, "AppData", "Roaming");
105
94
  const xdgConfigHome =
106
- readEnvironmentVariable("XDG_CONFIG_HOME") || join(home, ".config");
95
+ (ownHome && readEnvironmentVariable("XDG_CONFIG_HOME")) ||
96
+ join(home, ".config");
107
97
  const xdgDataHome =
108
- readEnvironmentVariable("XDG_DATA_HOME") || join(home, ".local", "share");
98
+ (ownHome && readEnvironmentVariable("XDG_DATA_HOME")) ||
99
+ join(home, ".local", "share");
109
100
 
110
101
  return [
111
102
  {
112
103
  tool: "kiro-cli",
113
104
  name: "Kiro CLI",
114
- dirs: [join(home, ".kiro")],
105
+ // The Kiro editor also creates ~/.kiro, so the CLI is recognised by the
106
+ // files only the CLI writes there.
107
+ dirs: [
108
+ join(home, ".kiro", "settings", "cli.json"),
109
+ join(home, ".kiro", "sessions", "cli"),
110
+ ],
111
+ homeDir: join(home, ".kiro"),
115
112
  supportDirs: isWin
116
113
  ? [join(appData, "kiro-cli")]
117
114
  : isMac
@@ -136,6 +133,8 @@ export function getAgenticToolDirs() {
136
133
  tool: "claude-code",
137
134
  name: "Claude Code",
138
135
  dirs: [join(home, ".claude")],
136
+ // User-scope MCP servers live in ~/.claude.json, beside the folder.
137
+ mcpConfigs: [{ file: join(home, ".claude.json"), key: "mcpServers" }],
139
138
  },
140
139
  {
141
140
  tool: "codex",
@@ -161,11 +160,111 @@ export function getAgenticToolDirs() {
161
160
  tool: "cursor",
162
161
  name: "Cursor",
163
162
  dirs: [join(home, ".cursor")],
163
+ mcpConfigs: [{ file: "mcp.json", key: "mcpServers" }],
164
+ ide: "cursor",
164
165
  },
165
166
  {
166
167
  tool: "windsurf",
167
168
  name: "Windsurf",
168
169
  dirs: [join(home, ".codeium", "windsurf")],
170
+ mcpConfigs: [{ file: "mcp_config.json", key: "mcpServers" }],
171
+ ide: "windsurf",
172
+ },
173
+ {
174
+ tool: "devin-desktop",
175
+ name: "Devin Desktop",
176
+ dirs: [join(home, ".devin")],
177
+ mcpConfigs: [
178
+ {
179
+ file: join(xdgConfigHome, "devin", "config.json"),
180
+ key: "mcpServers",
181
+ },
182
+ ],
183
+ ide: "devin-desktop",
184
+ },
185
+ {
186
+ tool: "kiro",
187
+ name: "Kiro",
188
+ // Kiro and Kiro CLI share ~/.kiro; the IDE is told apart by its
189
+ // extensions folder.
190
+ dirs: [join(home, ".kiro", "extensions")],
191
+ homeDir: join(home, ".kiro"),
192
+ mcpConfigs: [{ file: join("settings", "mcp.json"), key: "mcpServers" }],
193
+ ide: "kiro",
194
+ },
195
+ {
196
+ tool: "trae",
197
+ name: "Trae",
198
+ dirs: [join(home, ".trae")],
199
+ ide: "trae",
200
+ },
201
+ {
202
+ tool: "antigravity",
203
+ name: "Antigravity",
204
+ dirs: [join(home, ".antigravity")],
205
+ ide: "antigravity",
206
+ },
207
+ {
208
+ tool: "antigravity-ide",
209
+ name: "Antigravity IDE",
210
+ dirs: [join(home, ".antigravity-ide")],
211
+ ide: "antigravity-ide",
212
+ },
213
+ {
214
+ tool: "qoder",
215
+ name: "Qoder",
216
+ dirs: [join(home, ".qoder")],
217
+ ide: "qoder",
218
+ },
219
+ {
220
+ tool: "void",
221
+ name: "Void",
222
+ dirs: [join(home, ".void-editor")],
223
+ ide: "void",
224
+ },
225
+ {
226
+ tool: "qwen-code",
227
+ name: "Qwen Code",
228
+ dirs: [join(home, ".qwen")],
229
+ mcpConfigs: [{ file: "settings.json", key: "mcpServers" }],
230
+ },
231
+ {
232
+ tool: "factory-droid",
233
+ name: "Factory Droid",
234
+ dirs: [join(home, ".factory")],
235
+ mcpConfigs: [{ file: "mcp.json", key: "mcpServers" }],
236
+ },
237
+ {
238
+ tool: "auggie",
239
+ name: "Augment Auggie CLI",
240
+ dirs: [join(home, ".augment")],
241
+ mcpConfigs: [{ file: "settings.json", key: "mcpServers" }],
242
+ },
243
+ {
244
+ tool: "continue",
245
+ name: "Continue",
246
+ dirs: [join(home, ".continue")],
247
+ },
248
+ {
249
+ tool: "cline-cli",
250
+ name: "Cline CLI",
251
+ dirs: [join(home, ".cline")],
252
+ mcpConfigs: [
253
+ {
254
+ file: join("data", "settings", "cline_mcp_settings.json"),
255
+ key: "mcpServers",
256
+ },
257
+ ],
258
+ },
259
+ {
260
+ tool: "claude-desktop",
261
+ name: "Claude Desktop",
262
+ dirs: isWin
263
+ ? [join(appData, "Claude")]
264
+ : isMac
265
+ ? [join(home, "Library", "Application Support", "Claude")]
266
+ : [join(xdgConfigHome, "Claude")],
267
+ mcpConfigs: [{ file: "claude_desktop_config.json", key: "mcpServers" }],
169
268
  },
170
269
  {
171
270
  tool: "goose",
@@ -184,11 +283,16 @@ export function getAgenticToolDirs() {
184
283
  join(home, ".config", "amp"),
185
284
  ...(isWin ? [join(appData, "amp")] : []),
186
285
  ],
286
+ mcpConfigs: [
287
+ { file: "settings.json", key: "amp.mcpServers" },
288
+ { file: "settings.jsonc", key: "amp.mcpServers" },
289
+ ],
187
290
  },
188
291
  {
189
292
  tool: "copilot-cli",
190
293
  name: "GitHub Copilot CLI",
191
294
  dirs: [join(home, ".copilot-cli"), join(home, ".copilot")],
295
+ mcpConfigs: [{ file: "mcp-config.json", key: "mcpServers" }],
192
296
  },
193
297
  {
194
298
  tool: "crush",
@@ -206,27 +310,52 @@ export function getAgenticToolDirs() {
206
310
  * Discover which agentic CLI tools are installed for the current user by
207
311
  * checking their known home directories.
208
312
  *
209
- * @returns {Array<{tool: string, name: string, dir: string, supportDir?: string}>}
313
+ * @param {string} [home] Home directory to resolve the folders against
314
+ * @returns {Array<{tool: string, name: string, dir: string, supportDir?: string, mcpConfigs?: Object[], ide?: string}>}
210
315
  * One entry per discovered tool, using the first existing directory.
211
316
  */
212
- export function discoverAgenticTools() {
317
+ export function discoverAgenticTools(home = homedir()) {
213
318
  const found = [];
214
- for (const entry of getAgenticToolDirs()) {
215
- const dir = entry.dirs.find((candidate) => safeExistsSync(candidate));
216
- if (!dir) {
319
+ for (const entry of getAgenticToolDirs(home)) {
320
+ const marker = entry.dirs.find((candidate) => safeExistsSync(candidate));
321
+ if (!marker) {
217
322
  continue;
218
323
  }
219
324
  const supportDir = (entry.supportDirs || []).find((candidate) =>
220
325
  safeExistsSync(candidate),
221
326
  );
222
- found.push({ tool: entry.tool, name: entry.name, dir, supportDir });
327
+ found.push({
328
+ tool: entry.tool,
329
+ name: entry.name,
330
+ dir: entry.homeDir || marker,
331
+ supportDir,
332
+ mcpConfigs: entry.mcpConfigs,
333
+ ide: entry.ide,
334
+ });
223
335
  }
224
336
  return found;
225
337
  }
226
338
 
339
+ // Largest tool configuration file read. ~/.claude.json keeps per-project state
340
+ // and grows with use, so the cap is generous; it only has to stop a FIFO, a
341
+ // device or a runaway file from stalling the scan or exhausting memory.
342
+ const MAX_CONFIG_FILE_BYTES = 64 * 1024 * 1024;
343
+
344
+ /**
345
+ * Read a tool configuration file only when it is a regular file of bounded
346
+ * size. A FIFO or a device reads as undefined, and so does a symlink when the
347
+ * scan runs as root.
348
+ *
349
+ * @param {string} filePath Absolute path
350
+ * @returns {string|undefined} File text, or undefined
351
+ */
352
+ function readConfigText(filePath) {
353
+ return readUserFileText(filePath, MAX_CONFIG_FILE_BYTES);
354
+ }
355
+
227
356
  function parseJsonFile(filePath) {
228
357
  try {
229
- return parseJsonLikeText(readFileSync(filePath, "utf-8"));
358
+ return parseJsonLikeText(readConfigText(filePath));
230
359
  } catch {
231
360
  return undefined;
232
361
  }
@@ -335,7 +464,52 @@ function countDirEntries(dirPath, filterFn) {
335
464
  * @param {string} [supportDir] Optional platform support directory
336
465
  * @returns {string|undefined} Version string when found
337
466
  */
467
+ /**
468
+ * Read a semver-like version from the start of a release folder name such as
469
+ * `2.1.278` or `0.137.0-aarch64-apple-darwin`.
470
+ *
471
+ * @param {string} name Folder name
472
+ * @returns {string|undefined} The version, or undefined
473
+ */
474
+ function versionFromReleaseName(name) {
475
+ const match = /^v?(\d+\.\d+\.\d+)/u.exec(`${name || ""}`);
476
+ return match ? match[1] : undefined;
477
+ }
478
+
479
+ /**
480
+ * Follow a launcher symlink that points into a per-version folder, as the
481
+ * native Claude Code and Codex installers do, and read the version from the
482
+ * folder name.
483
+ *
484
+ * @param {string} linkPath Launcher or `current` symlink
485
+ * @returns {string|undefined} The version, or undefined
486
+ */
487
+ function versionFromReleaseLink(linkPath) {
488
+ try {
489
+ return versionFromReleaseName(basename(realpathSync(linkPath)));
490
+ } catch {
491
+ return undefined;
492
+ }
493
+ }
494
+
338
495
  function readToolVersion(tool, dir, supportDir) {
496
+ if (tool === "claude-code") {
497
+ const home = dirname(dir);
498
+ const version =
499
+ versionFromReleaseLink(join(home, ".local", "bin", "claude")) ||
500
+ versionFromReleaseLink(join(home, ".claude", "local", "claude"));
501
+ if (version) {
502
+ return version;
503
+ }
504
+ }
505
+ if (tool === "codex") {
506
+ const version = versionFromReleaseLink(
507
+ join(dir, "packages", "standalone", "current"),
508
+ );
509
+ if (version) {
510
+ return version;
511
+ }
512
+ }
339
513
  const candidates = [];
340
514
  if (tool === "opencode") {
341
515
  // ~/.opencode/package.json pins the installed opencode plugin runtime,
@@ -370,7 +544,7 @@ function readToolVersion(tool, dir, supportDir) {
370
544
  }
371
545
  } else {
372
546
  try {
373
- const raw = readFileSync(candidate, "utf-8").trim();
547
+ const raw = (readConfigText(candidate) || "").trim();
374
548
  if (/^v?\d+\.\d+/.test(raw)) {
375
549
  return raw.replace(/^v/, "");
376
550
  }
@@ -476,7 +650,7 @@ function countCodexSignals(dir) {
476
650
  return signals;
477
651
  }
478
652
  try {
479
- const config = parseToml(readFileSync(configPath, "utf-8"));
653
+ const config = parseToml(readConfigText(configPath) || "");
480
654
  if (config?.mcp_servers && typeof config.mcp_servers === "object") {
481
655
  signals.mcpServerCount = Object.keys(config.mcp_servers).length;
482
656
  }
@@ -488,24 +662,35 @@ function countCodexSignals(dir) {
488
662
 
489
663
  function countCursorSignals(dir) {
490
664
  const signals = {};
491
- const mcpConfig = parseJsonFile(join(dir, "mcp.json"));
492
- if (mcpConfig?.mcpServers && typeof mcpConfig.mcpServers === "object") {
493
- signals.mcpServerCount = Object.keys(mcpConfig.mcpServers).length;
494
- }
495
665
  const agentsDir = join(dir, "agents");
496
666
  if (safeExistsSync(agentsDir)) {
497
667
  signals.agentCount = countDirEntries(agentsDir, (name) =>
498
668
  name.endsWith(".md"),
499
669
  );
500
670
  }
501
- return signals;
502
- }
503
-
504
- function countWindsurfSignals(dir) {
505
- const signals = {};
506
- const mcpConfig = parseJsonFile(join(dir, "mcp_config.json"));
507
- if (mcpConfig?.mcpServers && typeof mcpConfig.mcpServers === "object") {
508
- signals.mcpServerCount = Object.keys(mcpConfig.mcpServers).length;
671
+ const rulesDir = join(dir, "rules");
672
+ if (safeExistsSync(rulesDir)) {
673
+ signals.ruleCount = countDirEntries(rulesDir, (name) =>
674
+ /\.(md|mdc)$/u.test(name),
675
+ );
676
+ }
677
+ for (const [skillsDir, key] of [
678
+ [join(dir, "skills"), "skillCount"],
679
+ [join(dir, "skills-cursor"), "bundledSkillCount"],
680
+ ]) {
681
+ if (safeExistsSync(skillsDir)) {
682
+ signals[key] = countDirEntries(skillsDir);
683
+ }
684
+ }
685
+ // The Cursor CLI keeps its tool permissions in cli-config.json. Only the
686
+ // number of rules is recorded, not the commands or paths they name.
687
+ const cliConfig = parseJsonFile(join(dir, "cli-config.json"));
688
+ const permissions = cliConfig?.permissions;
689
+ if (Array.isArray(permissions?.allow)) {
690
+ signals.cliAllowRuleCount = permissions.allow.length;
691
+ }
692
+ if (Array.isArray(permissions?.deny)) {
693
+ signals.cliDenyRuleCount = permissions.deny.length;
509
694
  }
510
695
  return signals;
511
696
  }
@@ -514,7 +699,7 @@ function countGooseSignals(dir) {
514
699
  const signals = {};
515
700
  // Goose keeps its resolved extension set in config.yaml.
516
701
  try {
517
- const config = loadYaml(readFileSync(join(dir, "config.yaml"), "utf-8"));
702
+ const config = loadYaml(readConfigText(join(dir, "config.yaml")) || "");
518
703
  if (Array.isArray(config?.extensions)) {
519
704
  signals.extensionCount = config.extensions.length;
520
705
  }
@@ -546,6 +731,81 @@ function countGeminiSignals(dir) {
546
731
  return signals;
547
732
  }
548
733
 
734
+ function countClaudeDesktopSignals(dir) {
735
+ const signals = {};
736
+ // Desktop extensions (.mcpb/.dxt) each bundle an MCP server.
737
+ const extensionsDir = join(dir, "Claude Extensions");
738
+ if (safeExistsSync(extensionsDir)) {
739
+ signals.extensionCount = countDirEntries(extensionsDir);
740
+ }
741
+ return signals;
742
+ }
743
+
744
+ /**
745
+ * Count the MCP servers a tool lists across its configuration files. A server
746
+ * named in two files is counted once.
747
+ *
748
+ * @param {string} dir Tool home directory, which relative files resolve against
749
+ * @param {Array<{file: string, key: string}>} [mcpConfigs] Files and the key
750
+ * holding the server map. A key is looked up as written first, so a dotted
751
+ * key such as `amp.mcpServers` matches a literal property.
752
+ * @returns {number|undefined} Number of servers, or undefined when no file
753
+ * defines any
754
+ */
755
+ export function countConfiguredMcpServers(dir, mcpConfigs) {
756
+ const names = new Set();
757
+ let sawConfig = false;
758
+ for (const { file, key } of mcpConfigs || []) {
759
+ const config = parseJsonFile(isAbsolute(file) ? file : join(dir, file));
760
+ if (!config || typeof config !== "object") {
761
+ continue;
762
+ }
763
+ let servers = config[key];
764
+ if (servers === undefined && key.includes(".")) {
765
+ servers = key.split(".").reduce((value, part) => value?.[part], config);
766
+ }
767
+ if (servers && typeof servers === "object" && !Array.isArray(servers)) {
768
+ sawConfig = true;
769
+ for (const name of Object.keys(servers)) {
770
+ names.add(name);
771
+ }
772
+ }
773
+ }
774
+ return sawConfig ? names.size : undefined;
775
+ }
776
+
777
+ /**
778
+ * Describe the editor an agentic tool ships in: its installed version and
779
+ * how many extensions are in its extensions folder.
780
+ *
781
+ * @param {string} ideId Entry id from IDE_PRODUCTS
782
+ * @param {string} home Home directory
783
+ * @returns {{version?: string, installDir?: string, extensionCount?: number}}
784
+ * What could be read
785
+ */
786
+ function describeAgenticIde(ideId, home) {
787
+ const product = IDE_PRODUCTS.find((p) => p.id === ideId);
788
+ if (!product) {
789
+ return {};
790
+ }
791
+ const info = findIdeProductInfo({ id: ideId, kind: "desktop" }, home);
792
+ const result = {};
793
+ if (info?.version) {
794
+ result.version = info.version;
795
+ }
796
+ if (info?.appDir) {
797
+ result.installDir = info.appDir;
798
+ }
799
+ const extensionsDir = join(home, product.dataFolderName, "extensions");
800
+ if (safeExistsSync(extensionsDir)) {
801
+ result.extensionCount = countDirEntries(
802
+ extensionsDir,
803
+ (name) => !name.startsWith(".") && name !== "extensions.json",
804
+ );
805
+ }
806
+ return result;
807
+ }
808
+
549
809
  /**
550
810
  * Compute safe, redacted signal counts for a discovered tool. Only structural
551
811
  * counts and small enumerated values are returned; no credential, transcript,
@@ -554,9 +814,34 @@ function countGeminiSignals(dir) {
554
814
  * @param {string} tool Tool key
555
815
  * @param {string} dir Discovered tool home directory
556
816
  * @param {string} [supportDir] Optional platform support directory
817
+ * @param {Array<{file: string, key: string}>} [mcpConfigs] Files that list
818
+ * the tool's MCP servers, used when the tool has no reader of its own
819
+ * @param {string} [home] Home directory the tool was found in
557
820
  * @returns {Object} Map of signal name to count/boolean/small-string value
558
821
  */
559
- export function collectToolSignals(tool, dir, supportDir) {
822
+ export function collectToolSignals(
823
+ tool,
824
+ dir,
825
+ supportDir,
826
+ mcpConfigs,
827
+ home = homedir(),
828
+ ) {
829
+ const signals = toolSpecificSignals(tool, dir, supportDir);
830
+ if (signals.mcpServerCount === undefined) {
831
+ const mcpServerCount = countConfiguredMcpServers(
832
+ dir,
833
+ mcpConfigs ||
834
+ getAgenticToolDirs(home).find((entry) => entry.tool === tool)
835
+ ?.mcpConfigs || [{ file: ".mcp.json", key: "mcpServers" }],
836
+ );
837
+ if (mcpServerCount !== undefined) {
838
+ signals.mcpServerCount = mcpServerCount;
839
+ }
840
+ }
841
+ return signals;
842
+ }
843
+
844
+ function toolSpecificSignals(tool, dir, supportDir) {
560
845
  switch (tool) {
561
846
  case "kiro-cli":
562
847
  return countKiroSignals(dir, supportDir);
@@ -572,48 +857,60 @@ export function collectToolSignals(tool, dir, supportDir) {
572
857
  return countGeminiSignals(dir);
573
858
  case "cursor":
574
859
  return countCursorSignals(dir);
575
- case "windsurf":
576
- return countWindsurfSignals(dir);
860
+ case "claude-desktop":
861
+ return countClaudeDesktopSignals(dir);
577
862
  case "goose":
578
863
  return countGooseSignals(dir);
579
864
  case "crush":
580
865
  return countCrushSignals(dir);
581
- default: {
582
- // Generic fallback: count top-level, non-sensitive config entries.
583
- const signals = {};
584
- const mcpConfig = parseJsonFile(join(dir, ".mcp.json"));
585
- if (mcpConfig?.mcpServers) {
586
- signals.mcpServerCount = Object.keys(mcpConfig.mcpServers).length;
587
- }
588
- return signals;
589
- }
866
+ default:
867
+ return {};
590
868
  }
591
869
  }
592
870
 
593
871
  /**
594
872
  * Build a CycloneDX application component for one discovered agentic tool.
595
873
  *
596
- * @param {{tool: string, name: string, dir: string, supportDir?: string}} entry
874
+ * @param {{tool: string, name: string, dir: string, supportDir?: string, mcpConfigs?: Object[], ide?: string}} entry
597
875
  * Discovered tool entry
876
+ * @param {string} [home] Home directory, used to find the tool's editor
598
877
  * @returns {Object} CycloneDX component
599
878
  */
600
- export function componentForAgenticTool(entry) {
601
- const { tool, name, dir, supportDir } = entry;
602
- const version = readToolVersion(tool, dir, supportDir);
879
+ export function componentForAgenticTool(entry, home = homedir()) {
880
+ const { tool, name, dir, supportDir, mcpConfigs, ide } = entry;
881
+ const ideInfo = ide ? describeAgenticIde(ide, home) : {};
882
+ const version = readToolVersion(tool, dir, supportDir) || ideInfo.version;
603
883
  const purl = buildGenericPurl(tool, version);
604
884
  const properties = [
605
- { name: "internal:SrcFile", value: redactHomePath(dir) },
885
+ { name: "internal:SrcFile", value: redactHomePath(dir, home) },
606
886
  { name: "cdx:agentic:tool", value: tool },
607
- { name: "cdx:agentic:home", value: redactHomePath(dir) },
887
+ { name: "cdx:agentic:home", value: redactHomePath(dir, home) },
608
888
  { name: "cdx:agentic:inventorySource", value: "host-scan" },
609
889
  ];
610
890
  if (supportDir) {
611
891
  properties.push({
612
892
  name: "cdx:agentic:supportDir",
613
- value: redactHomePath(supportDir),
893
+ value: redactHomePath(supportDir, home),
614
894
  });
615
895
  }
616
- const signals = collectToolSignals(tool, dir, supportDir);
896
+ // ~/.cursor or ~/.codeium/windsurf can exist with only a CLI or a plugin
897
+ // installed, so the editor is named only when it was found.
898
+ const ideFound =
899
+ Boolean(ide) &&
900
+ (ideInfo.installDir !== undefined || ideInfo.extensionCount !== undefined);
901
+ if (ideFound) {
902
+ properties.push({ name: "cdx:agentic:ide", value: ide });
903
+ }
904
+ if (ideInfo.installDir) {
905
+ properties.push({
906
+ name: "cdx:agentic:ideInstallDir",
907
+ value: redactHomePath(ideInfo.installDir, home),
908
+ });
909
+ }
910
+ const signals = collectToolSignals(tool, dir, supportDir, mcpConfigs, home);
911
+ if (ideInfo.extensionCount !== undefined) {
912
+ signals.extensionCount = ideInfo.extensionCount;
913
+ }
617
914
  for (const [key, value] of Object.entries(signals)) {
618
915
  if (value === undefined || value === null || value === "") {
619
916
  continue;
@@ -639,7 +936,12 @@ export function componentForAgenticTool(entry) {
639
936
  if (purl) {
640
937
  component.purl = purl;
641
938
  }
642
- component.description = `${name} agentic CLI tool discovered on the host`;
939
+ component.description = ideFound
940
+ ? `${name} AI editor discovered on the host`
941
+ : `${name} agentic CLI tool discovered on the host`;
942
+ if (ideFound) {
943
+ component.tags.push("ai-ide");
944
+ }
643
945
  return component;
644
946
  }
645
947
 
@@ -656,9 +958,10 @@ function childBomRef(parentRef, kind, id) {
656
958
  *
657
959
  * @param {string} dir Kiro home directory
658
960
  * @param {string} parentRef Parent tool bom-ref
961
+ * @param {string} [home] Home directory the tool was found in, for redaction
659
962
  * @returns {Object[]} Child components
660
963
  */
661
- function parseKiroAgents(dir, parentRef) {
964
+ function parseKiroAgents(dir, parentRef, home = homedir()) {
662
965
  const agentsDir = join(dir, "agents");
663
966
  if (!safeExistsSync(agentsDir)) {
664
967
  return [];
@@ -681,7 +984,7 @@ function parseKiroAgents(dir, parentRef) {
681
984
  const properties = [
682
985
  {
683
986
  name: "internal:SrcFile",
684
- value: redactHomePath(join(agentsDir, file)),
987
+ value: redactHomePath(join(agentsDir, file), home),
685
988
  },
686
989
  { name: "cdx:agentic:tool", value: "kiro-cli" },
687
990
  { name: "cdx:agentic:kind", value: "agent-definition" },
@@ -725,9 +1028,10 @@ function parseKiroAgents(dir, parentRef) {
725
1028
  *
726
1029
  * @param {string} dir zcode home directory
727
1030
  * @param {string} parentRef Parent tool bom-ref
1031
+ * @param {string} [home] Home directory the tool was found in, for redaction
728
1032
  * @returns {Object[]} Child components
729
1033
  */
730
- function parseZcodePlugins(dir, parentRef) {
1034
+ function parseZcodePlugins(dir, parentRef, home = homedir()) {
731
1035
  const cacheDir = join(dir, "cli", "plugins", "cache");
732
1036
  if (!safeExistsSync(cacheDir)) {
733
1037
  return [];
@@ -763,7 +1067,7 @@ function parseZcodePlugins(dir, parentRef) {
763
1067
  typeof plugin.version === "string" ? plugin.version : undefined,
764
1068
  );
765
1069
  const properties = [
766
- { name: "internal:SrcFile", value: redactHomePath(full) },
1070
+ { name: "internal:SrcFile", value: redactHomePath(full, home) },
767
1071
  { name: "cdx:agentic:tool", value: "zcode" },
768
1072
  { name: "cdx:agentic:kind", value: "plugin" },
769
1073
  ];
@@ -813,6 +1117,7 @@ function parseZcodePlugins(dir, parentRef) {
813
1117
  *
814
1118
  * @param {string} dir opencode home directory
815
1119
  * @param {string} parentRef Parent tool bom-ref
1120
+ * @param {string} [home] Home directory the tool was found in, for redaction
816
1121
  * @returns {Object[]} Child components
817
1122
  */
818
1123
  /**
@@ -823,9 +1128,10 @@ function parseZcodePlugins(dir, parentRef) {
823
1128
  *
824
1129
  * @param {string} dir opencode home directory
825
1130
  * @param {string} parentRef Parent tool bom-ref
1131
+ * @param {string} [home] Home directory the tool was found in, for redaction
826
1132
  * @returns {Object[]} Child provider services
827
1133
  */
828
- function parseOpencodeProviders(dir, parentRef) {
1134
+ function parseOpencodeProviders(dir, parentRef, home = homedir()) {
829
1135
  const jsonPath = join(dir, "opencode.json");
830
1136
  const jsoncPath = join(dir, "opencode.jsonc");
831
1137
  const usedPath = safeExistsSync(jsonPath) ? jsonPath : jsoncPath;
@@ -840,7 +1146,7 @@ function parseOpencodeProviders(dir, parentRef) {
840
1146
  group: providerName,
841
1147
  name: providerName,
842
1148
  properties: [
843
- { name: "internal:SrcFile", value: redactHomePath(usedPath) },
1149
+ { name: "internal:SrcFile", value: redactHomePath(usedPath, home) },
844
1150
  { name: "cdx:agentic:tool", value: "opencode" },
845
1151
  { name: "cdx:agentic:kind", value: "provider" },
846
1152
  { name: "cdx:ai:kind", value: "inference-service" },
@@ -857,18 +1163,25 @@ function parseOpencodeProviders(dir, parentRef) {
857
1163
  * @param {string} tool Tool key
858
1164
  * @param {string} dir Tool home directory
859
1165
  * @param {string} parentRef Parent tool bom-ref
1166
+ * @param {string} [home] Home directory the tool was found in, for redaction
860
1167
  * @returns {{components: Object[], services: Object[]}} Child inventory
861
1168
  */
862
- function collectToolChildren(tool, dir, parentRef) {
1169
+ function collectToolChildren(tool, dir, parentRef, home = homedir()) {
863
1170
  switch (tool) {
864
1171
  case "kiro-cli":
865
- return { components: parseKiroAgents(dir, parentRef), services: [] };
1172
+ return {
1173
+ components: parseKiroAgents(dir, parentRef, home),
1174
+ services: [],
1175
+ };
866
1176
  case "zcode":
867
- return { components: parseZcodePlugins(dir, parentRef), services: [] };
1177
+ return {
1178
+ components: parseZcodePlugins(dir, parentRef, home),
1179
+ services: [],
1180
+ };
868
1181
  case "opencode":
869
1182
  return {
870
1183
  components: [],
871
- services: parseOpencodeProviders(dir, parentRef),
1184
+ services: parseOpencodeProviders(dir, parentRef, home),
872
1185
  };
873
1186
  default:
874
1187
  return { components: [], services: [] };
@@ -882,22 +1195,29 @@ function collectToolChildren(tool, dir, parentRef) {
882
1195
  *
883
1196
  * This host scan is unconditional: it does not consult scan filters such as
884
1197
  * `--exclude` or profile/technique options, because it inventories fixed tool
885
- * home directories rather than a project tree. The `options` argument is
886
- * accepted for signature parity and is intentionally not used to alter results.
1198
+ * home directories rather than a project tree.
887
1199
  *
888
- * @param {Object} [options={}] Collection options (accepted but not applied)
1200
+ * @param {Object} [options={}] Collection options
1201
+ * @param {string} [options.home] Home directory to scan, defaulting to the
1202
+ * current user's
889
1203
  * @returns {{components: Object[], services: Object[], dependencies: Object[]}} Discovered inventory
890
1204
  */
891
- export function collectAgenticTools(_options = {}) {
1205
+ export function collectAgenticTools(options = {}) {
892
1206
  const components = [];
893
1207
  const services = [];
894
1208
  const dependencies = [];
895
- for (const entry of discoverAgenticTools()) {
1209
+ const home = options.home || homedir();
1210
+ for (const entry of discoverAgenticTools(home)) {
896
1211
  try {
897
- const toolComponent = componentForAgenticTool(entry);
1212
+ const toolComponent = componentForAgenticTool(entry, home);
898
1213
  components.push(toolComponent);
899
1214
  const parentRef = toolComponent["bom-ref"];
900
- const children = collectToolChildren(entry.tool, entry.dir, parentRef);
1215
+ const children = collectToolChildren(
1216
+ entry.tool,
1217
+ entry.dir,
1218
+ parentRef,
1219
+ home,
1220
+ );
901
1221
  const childRefs = [];
902
1222
  if (children.components.length) {
903
1223
  components.push(...children.components);