diff --git a/plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/.gitignore b/plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/.gitignore
new file mode 100644
index 0000000..006fc5f
--- /dev/null
+++ b/plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/.gitignore
@@ -0,0 +1,4 @@
+# Generated by tests/helpers/build-jetbrains-fixtures.mjs — do not commit.
+# JARs are built from source/ at test time for determinism and to keep the
+# repository free of binary blobs.
+**/lib/*.jar
diff --git a/plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/AndroidStudio2024.3.1/plugins/com.google.example/source/MANIFEST.MF b/plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/AndroidStudio2024.3.1/plugins/com.google.example/source/MANIFEST.MF
new file mode 100644
index 0000000..97d34b9
--- /dev/null
+++ b/plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/AndroidStudio2024.3.1/plugins/com.google.example/source/MANIFEST.MF
@@ -0,0 +1,3 @@
+Manifest-Version: 1.0
+Implementation-Title: com.google.example
+Implementation-Version: 1.0.0
diff --git a/plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/AndroidStudio2024.3.1/plugins/com.google.example/source/plugin.xml b/plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/AndroidStudio2024.3.1/plugins/com.google.example/source/plugin.xml
new file mode 100644
index 0000000..8f93225
--- /dev/null
+++ b/plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/AndroidStudio2024.3.1/plugins/com.google.example/source/plugin.xml
@@ -0,0 +1,10 @@
+
+
+ com.google.example
+ Android Studio Example
+ 1.0.0
+ Google
+ A plugin installed under the Android Studio base directory — discovery must find it.
+ com.intellij.modules.platform
+ org.jetbrains.android
+
diff --git a/plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/Fleet/plugins/com.example.fleet/source/MANIFEST.MF b/plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/Fleet/plugins/com.example.fleet/source/MANIFEST.MF
new file mode 100644
index 0000000..51501d1
--- /dev/null
+++ b/plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/Fleet/plugins/com.example.fleet/source/MANIFEST.MF
@@ -0,0 +1,3 @@
+Manifest-Version: 1.0
+Implementation-Title: com.example.fleet
+Implementation-Version: 1.0.0
diff --git a/plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/Fleet/plugins/com.example.fleet/source/plugin.xml b/plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/Fleet/plugins/com.example.fleet/source/plugin.xml
new file mode 100644
index 0000000..1e2375b
--- /dev/null
+++ b/plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/Fleet/plugins/com.example.fleet/source/plugin.xml
@@ -0,0 +1,8 @@
+
+
+ com.example.fleet
+ Fleet Plugin
+ 1.0.0
+ Example Inc
+ MUST be excluded by JetBrains discovery — Fleet uses a different plugin model.
+
diff --git a/plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/IntelliJIdea2024.3/plugins/com.example.benign/source/MANIFEST.MF b/plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/IntelliJIdea2024.3/plugins/com.example.benign/source/MANIFEST.MF
new file mode 100644
index 0000000..f3a6e65
--- /dev/null
+++ b/plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/IntelliJIdea2024.3/plugins/com.example.benign/source/MANIFEST.MF
@@ -0,0 +1,3 @@
+Manifest-Version: 1.0
+Implementation-Title: com.example.benign
+Implementation-Version: 1.0.0
diff --git a/plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/IntelliJIdea2024.3/plugins/com.example.benign/source/plugin.xml b/plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/IntelliJIdea2024.3/plugins/com.example.benign/source/plugin.xml
new file mode 100644
index 0000000..823fb17
--- /dev/null
+++ b/plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/IntelliJIdea2024.3/plugins/com.example.benign/source/plugin.xml
@@ -0,0 +1,10 @@
+
+
+ com.example.benign
+ Benign Example
+ 1.0.0
+ Example Inc
+ A well-behaved plugin with no flagged signals.
+
+ com.intellij.modules.platform
+
diff --git a/plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/IntelliJIdea2024.3/plugins/com.example.broad-activation/source/MANIFEST.MF b/plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/IntelliJIdea2024.3/plugins/com.example.broad-activation/source/MANIFEST.MF
new file mode 100644
index 0000000..480c9f4
--- /dev/null
+++ b/plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/IntelliJIdea2024.3/plugins/com.example.broad-activation/source/MANIFEST.MF
@@ -0,0 +1,3 @@
+Manifest-Version: 1.0
+Implementation-Title: com.example.broad-activation
+Implementation-Version: 1.0.0
diff --git a/plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/IntelliJIdea2024.3/plugins/com.example.broad-activation/source/plugin.xml b/plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/IntelliJIdea2024.3/plugins/com.example.broad-activation/source/plugin.xml
new file mode 100644
index 0000000..19ea46e
--- /dev/null
+++ b/plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/IntelliJIdea2024.3/plugins/com.example.broad-activation/source/plugin.xml
@@ -0,0 +1,14 @@
+
+
+ com.example.broad-activation
+ Broad Activation
+ 1.0.0
+ Example Inc
+ Declares legacy application-components — loads at IDE startup for every project.
+ com.intellij.modules.platform
+
+
+ com.example.EarlyBoot
+
+
+
diff --git a/plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/IntelliJIdea2024.3/plugins/com.example.depends-chain/source/MANIFEST.MF b/plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/IntelliJIdea2024.3/plugins/com.example.depends-chain/source/MANIFEST.MF
new file mode 100644
index 0000000..0b7a6b6
--- /dev/null
+++ b/plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/IntelliJIdea2024.3/plugins/com.example.depends-chain/source/MANIFEST.MF
@@ -0,0 +1,3 @@
+Manifest-Version: 1.0
+Implementation-Title: com.example.depends-chain
+Implementation-Version: 1.0.0
diff --git a/plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/IntelliJIdea2024.3/plugins/com.example.depends-chain/source/plugin.xml b/plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/IntelliJIdea2024.3/plugins/com.example.depends-chain/source/plugin.xml
new file mode 100644
index 0000000..3f1c9ea
--- /dev/null
+++ b/plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/IntelliJIdea2024.3/plugins/com.example.depends-chain/source/plugin.xml
@@ -0,0 +1,12 @@
+
+
+ com.example.depends-chain
+ Depends Chain
+ 1.0.0
+ Example Inc
+ Long mandatory dependency chain — amplifies blast radius if any dep is compromised.
+ com.intellij.modules.platform
+ com.intellij.modules.lang
+ com.intellij.modules.java
+ com.jetbrains.php
+
diff --git a/plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/IntelliJIdea2024.3/plugins/com.example.native-binary/source/MANIFEST.MF b/plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/IntelliJIdea2024.3/plugins/com.example.native-binary/source/MANIFEST.MF
new file mode 100644
index 0000000..9707837
--- /dev/null
+++ b/plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/IntelliJIdea2024.3/plugins/com.example.native-binary/source/MANIFEST.MF
@@ -0,0 +1,3 @@
+Manifest-Version: 1.0
+Implementation-Title: com.example.native-binary
+Implementation-Version: 1.0.0
diff --git a/plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/IntelliJIdea2024.3/plugins/com.example.native-binary/source/native/foo.so b/plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/IntelliJIdea2024.3/plugins/com.example.native-binary/source/native/foo.so
new file mode 100644
index 0000000..4b2c3a8
Binary files /dev/null and b/plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/IntelliJIdea2024.3/plugins/com.example.native-binary/source/native/foo.so differ
diff --git a/plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/IntelliJIdea2024.3/plugins/com.example.native-binary/source/plugin.xml b/plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/IntelliJIdea2024.3/plugins/com.example.native-binary/source/plugin.xml
new file mode 100644
index 0000000..b3debcf
--- /dev/null
+++ b/plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/IntelliJIdea2024.3/plugins/com.example.native-binary/source/plugin.xml
@@ -0,0 +1,9 @@
+
+
+ com.example.native-binary
+ Native Binary Plugin
+ 1.0.0
+ Example Inc
+ Ships a shared library under native/ — expands attack surface via JNI.
+ com.intellij.modules.platform
+
diff --git a/plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/IntelliJIdea2024.3/plugins/com.example.premain/source/MANIFEST.MF b/plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/IntelliJIdea2024.3/plugins/com.example.premain/source/MANIFEST.MF
new file mode 100644
index 0000000..9f1071e
--- /dev/null
+++ b/plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/IntelliJIdea2024.3/plugins/com.example.premain/source/MANIFEST.MF
@@ -0,0 +1,6 @@
+Manifest-Version: 1.0
+Implementation-Title: com.example.premain
+Implementation-Version: 1.0.0
+Premain-Class: com.example.Agent
+Can-Redefine-Classes: true
+Can-Retransform-Classes: true
diff --git a/plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/IntelliJIdea2024.3/plugins/com.example.premain/source/plugin.xml b/plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/IntelliJIdea2024.3/plugins/com.example.premain/source/plugin.xml
new file mode 100644
index 0000000..496ac22
--- /dev/null
+++ b/plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/IntelliJIdea2024.3/plugins/com.example.premain/source/plugin.xml
@@ -0,0 +1,9 @@
+
+
+ com.example.premain
+ Premain Agent Plugin
+ 1.0.0
+ Example Inc
+ Ships a java-agent via Premain-Class — can rewrite bytecode at startup.
+ com.intellij.modules.platform
+
diff --git a/plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/IntelliJIdea2024.3/plugins/com.example.theme-with-code/source/MANIFEST.MF b/plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/IntelliJIdea2024.3/plugins/com.example.theme-with-code/source/MANIFEST.MF
new file mode 100644
index 0000000..140644a
--- /dev/null
+++ b/plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/IntelliJIdea2024.3/plugins/com.example.theme-with-code/source/MANIFEST.MF
@@ -0,0 +1,3 @@
+Manifest-Version: 1.0
+Implementation-Title: com.example.theme-with-code
+Implementation-Version: 1.0.0
diff --git a/plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/IntelliJIdea2024.3/plugins/com.example.theme-with-code/source/plugin.xml b/plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/IntelliJIdea2024.3/plugins/com.example.theme-with-code/source/plugin.xml
new file mode 100644
index 0000000..254cbf5
--- /dev/null
+++ b/plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/IntelliJIdea2024.3/plugins/com.example.theme-with-code/source/plugin.xml
@@ -0,0 +1,13 @@
+
+
+ com.example.theme-with-code
+ Theme With Code
+ 1.0.0
+ Evil Inc
+ A theme plugin that smuggles executable services (Material Theme malware pattern).
+ com.intellij.modules.platform
+
+
+
+
+
diff --git a/plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/IntelliJIdea2024.3/plugins/com.intellij.jaba/source/MANIFEST.MF b/plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/IntelliJIdea2024.3/plugins/com.intellij.jaba/source/MANIFEST.MF
new file mode 100644
index 0000000..da26766
--- /dev/null
+++ b/plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/IntelliJIdea2024.3/plugins/com.intellij.jaba/source/MANIFEST.MF
@@ -0,0 +1,3 @@
+Manifest-Version: 1.0
+Implementation-Title: com.intellij.jaba
+Implementation-Version: 1.0.0
diff --git a/plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/IntelliJIdea2024.3/plugins/com.intellij.jaba/source/plugin.xml b/plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/IntelliJIdea2024.3/plugins/com.intellij.jaba/source/plugin.xml
new file mode 100644
index 0000000..ad0f72c
--- /dev/null
+++ b/plugins/llm-security/tests/fixtures/ide-extensions/root-jetbrains/IntelliJIdea2024.3/plugins/com.intellij.jaba/source/plugin.xml
@@ -0,0 +1,9 @@
+
+
+ com.intellij.jaba
+ Java (jaba)
+ 1.0.0
+ Impostor Inc
+ Typosquat of com.intellij.java (Levenshtein distance 1).
+ com.intellij.modules.platform
+
diff --git a/plugins/llm-security/tests/helpers/build-jetbrains-fixtures.mjs b/plugins/llm-security/tests/helpers/build-jetbrains-fixtures.mjs
new file mode 100644
index 0000000..c382310
--- /dev/null
+++ b/plugins/llm-security/tests/helpers/build-jetbrains-fixtures.mjs
@@ -0,0 +1,196 @@
+#!/usr/bin/env node
+// build-jetbrains-fixtures.mjs — Deterministic fixture-jar builder.
+//
+// Walks `tests/fixtures/ide-extensions/root-jetbrains/` and for every plugin
+// directory containing a `source/` subtree, produces `lib/.jar` by
+// packing the source files into a real ZIP with JetBrains-plugin layout:
+//
+// source/plugin.xml → META-INF/plugin.xml (inside jar)
+// source/MANIFEST.MF → META-INF/MANIFEST.MF (inside jar)
+// source/native/foo.so → native/foo.so (inside jar)
+// source/ → (inside jar)
+//
+// Invariants:
+// - Deterministic: stored-method, fixed DOS timestamp, sorted entries.
+// Same input → byte-identical output across Node versions / platforms.
+// - Idempotent + race-safe: each jar write is atomic (temp-then-rename).
+// If the target file already exists with the expected SHA-256 (computed
+// from the intended bytes), the write is skipped. Concurrent callers are
+// safe because rename is atomic on the same filesystem and every writer
+// produces identical bytes.
+// - Zero dependencies. Uses `createZip` from `../helpers/zip-writer.mjs`.
+//
+// Public API:
+// export function buildJetBrainsFixtures({ fixtureRoot? }) → { fixtureRoot, builtJars }
+// export function writeJar(finalPath, entries) → { path, sha256, skipped }
+//
+// CLI: `node tests/helpers/build-jetbrains-fixtures.mjs` — walks the default
+// fixture root, builds all jars, prints a one-line summary per jar.
+//
+// Called by:
+// - tests/scanners/ide-extension-scanner.test.mjs (Step 14 integration)
+// - any future test that needs the synthetic JetBrains plugin tree
+//
+// See: plan step 13 (`ultraplan-2026-04-17-jetbrains-ide-scan.md`).
+
+import { readdir, mkdir, rename, writeFile, readFile, stat } from 'node:fs/promises';
+import { existsSync } from 'node:fs';
+import { dirname, join, relative, sep } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { createHash } from 'node:crypto';
+import { createZip } from './zip-writer.mjs';
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+const DEFAULT_FIXTURE_ROOT = join(
+ __dirname,
+ '..',
+ 'fixtures',
+ 'ide-extensions',
+ 'root-jetbrains',
+);
+
+function sha256(buf) {
+ return createHash('sha256').update(buf).digest('hex');
+}
+
+/**
+ * Recursively walk a directory, returning relative paths of regular files.
+ * Relative paths use forward slashes (ZIP spec).
+ */
+async function walkFiles(root, rel = '') {
+ const out = [];
+ const full = rel ? join(root, rel) : root;
+ const entries = await readdir(full, { withFileTypes: true });
+ for (const ent of entries) {
+ const sub = rel ? `${rel}/${ent.name}` : ent.name;
+ if (ent.isDirectory()) {
+ out.push(...(await walkFiles(root, sub)));
+ } else if (ent.isFile()) {
+ out.push(sub);
+ }
+ }
+ return out;
+}
+
+/**
+ * Map a `source/`-relative path to the in-jar path.
+ *
+ * source/plugin.xml → META-INF/plugin.xml
+ * source/MANIFEST.MF → META-INF/MANIFEST.MF
+ * source/native/foo.so → native/foo.so
+ * source/ →
+ */
+function sourceToJarPath(srcRelPath) {
+ const p = srcRelPath.replace(/\\/g, '/');
+ if (p === 'plugin.xml') return 'META-INF/plugin.xml';
+ if (p === 'MANIFEST.MF') return 'META-INF/MANIFEST.MF';
+ return p;
+}
+
+/**
+ * Atomically write a file by staging into `.tmp.` and renaming.
+ * Rename is atomic on the same filesystem; if two writers race, the last
+ * rename wins — and since callers here pass identical bytes for a given
+ * fixture input, the final file is byte-identical regardless of winner.
+ */
+async function atomicWrite(finalPath, bytes) {
+ await mkdir(dirname(finalPath), { recursive: true });
+ const tmp = `${finalPath}.tmp.${process.pid}`;
+ await writeFile(tmp, bytes);
+ await rename(tmp, finalPath);
+}
+
+/**
+ * Build a single jar from a list of `{ name, data }` entries and write it to
+ * `finalPath`. Skips the write if `finalPath` already exists with the
+ * expected SHA-256 — makes the function idempotent and safe to call
+ * repeatedly (including concurrently from multiple test.before hooks).
+ *
+ * @returns {{ path: string, sha256: string, skipped: boolean }}
+ */
+export async function writeJar(finalPath, entries) {
+ const bytes = createZip(entries);
+ const expected = sha256(bytes);
+
+ if (existsSync(finalPath)) {
+ try {
+ const existing = await readFile(finalPath);
+ if (sha256(existing) === expected) {
+ return { path: finalPath, sha256: expected, skipped: true };
+ }
+ } catch {
+ // Fall through and rewrite.
+ }
+ }
+
+ await atomicWrite(finalPath, bytes);
+ return { path: finalPath, sha256: expected, skipped: false };
+}
+
+/**
+ * Build fixture jars for every plugin directory under the fixture root.
+ * A plugin directory is detected by the presence of a `source/plugin.xml`.
+ * The produced jar lands at `/lib/.jar`.
+ *
+ * @param {{ fixtureRoot?: string }} [opts]
+ * @returns {Promise<{ fixtureRoot: string, builtJars: Array<{ path: string, sha256: string, skipped: boolean, pluginId: string }> }>}
+ */
+export async function buildJetBrainsFixtures(opts = {}) {
+ const fixtureRoot = opts.fixtureRoot || DEFAULT_FIXTURE_ROOT;
+ if (!existsSync(fixtureRoot)) {
+ throw new Error(`fixture root does not exist: ${fixtureRoot}`);
+ }
+
+ const builtJars = [];
+
+ // Walk two levels deep: //plugins//source/plugin.xml
+ async function walkIDEs() {
+ const ideDirs = await readdir(fixtureRoot, { withFileTypes: true });
+ for (const ide of ideDirs) {
+ if (!ide.isDirectory()) continue;
+ const pluginsDir = join(fixtureRoot, ide.name, 'plugins');
+ if (!existsSync(pluginsDir)) continue;
+ const pluginEntries = await readdir(pluginsDir, { withFileTypes: true });
+ for (const p of pluginEntries) {
+ if (!p.isDirectory()) continue;
+ const pluginDir = join(pluginsDir, p.name);
+ const sourceDir = join(pluginDir, 'source');
+ if (!existsSync(join(sourceDir, 'plugin.xml'))) continue;
+
+ // Build jar entries from source tree.
+ const files = await walkFiles(sourceDir);
+ const entries = [];
+ for (const rel of files) {
+ const abs = join(sourceDir, rel);
+ const data = await readFile(abs);
+ entries.push({ name: sourceToJarPath(rel), data });
+ }
+
+ const jarPath = join(pluginDir, 'lib', `${p.name}.jar`);
+ const result = await writeJar(jarPath, entries);
+ builtJars.push({ ...result, pluginId: p.name });
+ }
+ }
+ }
+
+ await walkIDEs();
+ return { fixtureRoot, builtJars };
+}
+
+// CLI mode — invoked via `node tests/helpers/build-jetbrains-fixtures.mjs`.
+if (import.meta.url === `file://${process.argv[1]}`) {
+ buildJetBrainsFixtures()
+ .then(({ fixtureRoot, builtJars }) => {
+ const root = relative(process.cwd(), fixtureRoot);
+ for (const j of builtJars) {
+ const rel = relative(process.cwd(), j.path);
+ const tag = j.skipped ? 'skip' : ' new';
+ process.stdout.write(`[${tag}] ${rel}\n`);
+ }
+ process.stdout.write(`built ${builtJars.length} jar(s) under ${root}\n`);
+ })
+ .catch((err) => {
+ process.stderr.write(`build-jetbrains-fixtures: ${err.stack || err.message}\n`);
+ process.exit(1);
+ });
+}