diff --git a/plugins/llm-security/scanners/lib/ide-extension-discovery.mjs b/plugins/llm-security/scanners/lib/ide-extension-discovery.mjs
index f3552f0..1577bcd 100644
--- a/plugins/llm-security/scanners/lib/ide-extension-discovery.mjs
+++ b/plugins/llm-security/scanners/lib/ide-extension-discovery.mjs
@@ -52,6 +52,24 @@ export function getJetBrainsBaseDir() {
return existsSync(base) ? base : null;
}
+/**
+ * Return the Android Studio base directory. Diverges from JetBrains on Linux:
+ * Android Studio uses ~/.config/Google (config path) rather than ~/.local/share.
+ * @returns {string|null}
+ */
+export function getAndroidStudioBaseDir() {
+ const home = homedir();
+ let base;
+ if (process.platform === 'darwin') {
+ base = join(home, 'Library', 'Application Support', 'Google');
+ } else if (process.platform === 'win32') {
+ base = join(process.env.APPDATA || join(home, 'AppData', 'Roaming'), 'Google');
+ } else {
+ base = join(home, '.config', 'Google');
+ }
+ return existsSync(base) ? base : null;
+}
+
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
@@ -257,15 +275,99 @@ export async function discoverVSCodeExtensions(options = {}) {
// JetBrains discovery — stub (v1.1)
// ---------------------------------------------------------------------------
+// Matches JetBrains + Android Studio product directory names. Explicitly excludes
+// Fleet (separate SDK, different plugin model) and Toolbox (launcher cache dir).
+const JB_PRODUCT_DIR_RE = /^(IntelliJIdea|IdeaIC|PyCharm|PyCharmCE|WebStorm|GoLand|PhpStorm|RubyMine|CLion|DataGrip|Rider|RustRover|DataSpell|Aqua|AndroidStudio)([0-9]+\.[0-9]+(?:\.[0-9]+)?)?$/;
+
/**
- * Discover JetBrains plugins. Stub returns empty for v6.3.0.
+ * Discover JetBrains + Android Studio plugins from installed IDE directories.
+ *
+ * **`rootsOverride` semantics for JetBrains:** Each entry is a BASE directory —
+ * the equivalent of `getJetBrainsBaseDir()` output (the `JetBrains/` or
+ * `Google/` parent). The discovery walks `//plugins//`.
+ * This differs from VS Code where `rootsOverride` entries point at the
+ * `extensions/` level directly. Rationale: JetBrains has many product dirs per
+ * base (IntelliJIdea, PyCharm, GoLand, AndroidStudio…), so base-level injection
+ * is the natural analogue for tests.
+ *
+ * Fleet and Toolbox are always excluded.
+ *
* @param {object} [options]
+ * @param {string[]} [options.rootsOverride] - Base directories (not `plugins/` level).
+ * @param {boolean} [options.followSymlinks=false]
* @returns {Promise<{ extensions: ExtensionRecord[], warnings: string[], rootsScanned: string[] }>}
*/
export async function discoverJetBrainsExtensions(options = {}) {
- return {
- extensions: [],
- warnings: ['IntelliJ plugin discovery deferred to v1.1 (see knowledge/ide-extension-threat-patterns.md)'],
- rootsScanned: [],
- };
+ const warnings = [];
+ const extensions = [];
+ const rootsScanned = [];
+
+ let baseDirs;
+ if (Array.isArray(options.rootsOverride) && options.rootsOverride.length > 0) {
+ baseDirs = options.rootsOverride;
+ } else {
+ baseDirs = [getJetBrainsBaseDir(), getAndroidStudioBaseDir()].filter(Boolean);
+ }
+
+ for (const base of baseDirs) {
+ if (!await pathExists(base)) continue;
+ rootsScanned.push(base);
+
+ let productEntries;
+ try {
+ productEntries = await readdir(base, { withFileTypes: true });
+ } catch (err) {
+ warnings.push(`failed to read ${base}: ${err.message}`);
+ continue;
+ }
+
+ for (const pe of productEntries) {
+ if (!pe.isDirectory() && !(pe.isSymbolicLink() && options.followSymlinks)) continue;
+ if (pe.name.startsWith('.')) continue;
+ if (!JB_PRODUCT_DIR_RE.test(pe.name)) continue; // excludes Fleet, Toolbox
+
+ const productDir = join(base, pe.name);
+ const pluginsDir = join(productDir, 'plugins');
+ if (!await pathExists(pluginsDir)) continue;
+
+ let pluginEntries;
+ try {
+ pluginEntries = await readdir(pluginsDir, { withFileTypes: true });
+ } catch (err) {
+ warnings.push(`failed to read ${pluginsDir}: ${err.message}`);
+ continue;
+ }
+
+ for (const plug of pluginEntries) {
+ if (plug.name.startsWith('.')) continue;
+ if (plug.name === 'disabled_plugins.txt') continue;
+ if (plug.isSymbolicLink() && !options.followSymlinks) continue;
+ if (!plug.isDirectory() && !plug.isSymbolicLink()) continue;
+
+ const pluginDir = join(pluginsDir, plug.name);
+ // Safety: skip anything rooted under Toolbox cache
+ if (pluginDir.includes(join('Caches', 'JetBrains', 'Toolbox'))) continue;
+
+ const id = plug.name.toLowerCase();
+ extensions.push({
+ id,
+ publisher: '',
+ name: plug.name,
+ version: '',
+ location: pluginDir,
+ type: 'jetbrains',
+ source: null,
+ isBuiltin: false,
+ installedTimestamp: null,
+ targetPlatform: null,
+ publisherDisplayName: null,
+ signed: false,
+ rootDir: base,
+ productDir: pe.name,
+ });
+ }
+ }
+ }
+
+ return { extensions, warnings, rootsScanned };
}
diff --git a/plugins/llm-security/tests/scanners/ide-extension-discovery.test.mjs b/plugins/llm-security/tests/scanners/ide-extension-discovery.test.mjs
new file mode 100644
index 0000000..041fb75
--- /dev/null
+++ b/plugins/llm-security/tests/scanners/ide-extension-discovery.test.mjs
@@ -0,0 +1,100 @@
+// ide-extension-discovery.test.mjs — discoverJetBrainsExtensions integration tests.
+// All fixtures built in-test via mkdtemp + mkdir — no committed filesystem state.
+
+import { describe, it, after } from 'node:test';
+import assert from 'node:assert/strict';
+import { mkdtemp, mkdir, rm } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import {
+ discoverJetBrainsExtensions,
+ getAndroidStudioBaseDir,
+} from '../../scanners/lib/ide-extension-discovery.mjs';
+
+const TEST_PREFIX = 'llmsec-jbdisc-';
+const created = [];
+
+async function buildFixtureBase(layout) {
+ const base = await mkdtemp(join(tmpdir(), TEST_PREFIX));
+ created.push(base);
+ for (const [product, plugins] of Object.entries(layout)) {
+ for (const plug of plugins) {
+ await mkdir(join(base, product, 'plugins', plug), { recursive: true });
+ }
+ }
+ return base;
+}
+
+describe('discoverJetBrainsExtensions — basic walk', () => {
+ it('finds plugins under IntelliJIdea/plugins/', async () => {
+ const base = await buildFixtureBase({
+ 'IntelliJIdea2024.3': ['com.example.one', 'com.example.two'],
+ });
+ const res = await discoverJetBrainsExtensions({ rootsOverride: [base] });
+ assert.equal(res.extensions.length, 2);
+ assert.ok(res.extensions.every(e => e.type === 'jetbrains'));
+ assert.ok(res.extensions.some(e => e.name === 'com.example.one'));
+ assert.equal(res.extensions[0].productDir, 'IntelliJIdea2024.3');
+ });
+});
+
+describe('discoverJetBrainsExtensions — Fleet excluded', () => {
+ it('ignores Fleet directory', async () => {
+ const base = await buildFixtureBase({
+ 'IntelliJIdea2024.3': ['com.example.good'],
+ 'Fleet': ['com.example.fleet'],
+ });
+ const res = await discoverJetBrainsExtensions({ rootsOverride: [base] });
+ assert.equal(res.extensions.length, 1);
+ assert.equal(res.extensions[0].name, 'com.example.good');
+ assert.ok(res.extensions.every(e => !e.location.includes('Fleet')));
+ });
+});
+
+describe('discoverJetBrainsExtensions — Android Studio support', () => {
+ it('discovers from AndroidStudio product dir', async () => {
+ const base = await buildFixtureBase({
+ 'AndroidStudio2024.3.1': ['com.google.example'],
+ });
+ const res = await discoverJetBrainsExtensions({ rootsOverride: [base] });
+ assert.equal(res.extensions.length, 1);
+ assert.equal(res.extensions[0].productDir, 'AndroidStudio2024.3.1');
+ });
+});
+
+describe('discoverJetBrainsExtensions — multi-product', () => {
+ it('walks all matching product dirs', async () => {
+ const base = await buildFixtureBase({
+ 'IntelliJIdea2024.3': ['a'],
+ 'PyCharm2024.3': ['b'],
+ 'GoLand2024.3': ['c'],
+ 'Toolbox': ['d'], // excluded
+ });
+ const res = await discoverJetBrainsExtensions({ rootsOverride: [base] });
+ assert.equal(res.extensions.length, 3);
+ assert.ok(!res.extensions.some(e => e.location.includes('Toolbox')));
+ });
+});
+
+describe('discoverJetBrainsExtensions — skip hidden + disabled_plugins.txt', () => {
+ it('ignores dotfile plugins and disabled_plugins.txt', async () => {
+ const base = await buildFixtureBase({
+ 'IntelliJIdea2024.3': ['.hidden', 'com.real', 'disabled_plugins.txt'],
+ });
+ const res = await discoverJetBrainsExtensions({ rootsOverride: [base] });
+ assert.equal(res.extensions.length, 1);
+ assert.equal(res.extensions[0].name, 'com.real');
+ });
+});
+
+describe('getAndroidStudioBaseDir — Linux path divergence', () => {
+ it('is a path-producing function (no exception)', () => {
+ // Actual return value is null or a path depending on host — just assert no throw.
+ const v = getAndroidStudioBaseDir();
+ assert.ok(v === null || typeof v === 'string');
+ });
+});
+
+after(async () => {
+ for (const r of created) await rm(r, { recursive: true, force: true }).catch(() => {});
+});